Fix Code Mode workspace context propagation

This commit is contained in:
Yichen Jiang
2026-07-13 13:56:45 +08:00
parent c748f30055
commit 768c79fd45
51 changed files with 1040 additions and 265 deletions

View File

@@ -200,7 +200,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'register(definition: ToolDefinition): () => void',
'get(name: string): ToolDefinition | undefined',
'schemas(): ToolSchema[]',
'async execute(exec: ToolExecution): Promise<ToolExecutionResult>',
'async execute(request: ToolExecution): Promise<ToolExecutionResult>',
],
},
{
@@ -416,7 +416,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'tools/post-execute',
mode: 'waterfall',
signature: '\'tools/post-execute\'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContexts` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
},
{
name: 'tools/pre-execute',
@@ -906,7 +906,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolDefinition',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
},
{
name: 'ToolErrorInfo',
@@ -922,7 +922,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionResult',
declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}',
declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}',
},
{
name: 'ToolResult',
@@ -936,6 +936,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ToolResultView',
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;',
},
{
name: 'ToolRunContext',
declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n}',
},
{
name: 'ToolSchema',
declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',

View File

@@ -70,7 +70,7 @@ forever:
each tool-call: session('tool/call')
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
→ session('tool/result')
append buffered post-execute additionalContext as session('context/message')(s)
append buffered deferred/post-execute additionalContexts as session('context/message')(s)
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation → ContinuationDecision
({action:'continue', reason?} records reason as next-step steering)

View File

@@ -176,7 +176,7 @@ export interface LoopHandle {
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
* → dispatch → tools/post-execute
* session('tool/result')
* append buffered post-execute additionalContext → session('context/message')(s)
* append buffered deferred/post-execute contexts → session('context/message')(s)
* drain steering → session('steering/message')
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
@@ -870,13 +870,13 @@ async function runStep(
// --- Tool execution (sequential; parallel execution is a TODO) ---
// If this becomes parallel, audit post-execute plugins that keep per-step
// pending state before their returned additionalContext is appended.
// pending state before their returned contexts are appended.
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Per-step buffer of `additionalContext` attached by tools/post-execute
// listeners. Appended as context/message(s) only AFTER every tool/result for
// the step, so a multi-call step keeps tool-call/result adjacency
// Per-step buffer of contexts deferred by composite tools or attached by
// tools/post-execute listeners. Appended as context/message(s) only AFTER
// every tool/result for the step, so a multi-call step keeps adjacency
// (interleaving context between a call's result and the next call's would
// break the pairing the next model request relies on).
const pendingContext: HookContext[] = []
@@ -919,8 +919,8 @@ async function runStep(
// persisted so a UI bridge reproduces the card on replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
// Buffer (don't append yet) any post-execute additionalContext for this call.
if (result.additionalContext) pendingContext.push(result.additionalContext)
// Buffer (don't append yet) every context carried by this call.
pendingContext.push(...result.additionalContexts ?? [])
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */

View File

@@ -524,8 +524,8 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
})
})
describe('tools/post-execute additionalContext buffering across a multi-call step', () => {
it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
describe('tool additionalContexts buffering across a step', () => {
it('appends each call\'s contexts only AFTER all tool/results, preserving adjacency', async () => {
// One assistant step with TWO tool calls; the second model response stops.
const twoCalls = [
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
@@ -543,16 +543,16 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Each call attaches additionalContext naming itself.
// Each call attaches one context naming itself.
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
({
kind: 'accept',
additionalContext: {
additionalContexts: [{
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
source: { kind: 'plugin', plugin: 'p' },
envelope: 'raw',
meta: { callId: exec.callId },
},
}],
}))
send(agent, 'go')
@@ -577,6 +577,34 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw'])
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
})
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'composite', description: 'composite', parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } })
return [{ type: 'text', text: 'outer result' }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
const log = events(agent)
const resultIndex = log.findIndex(event => event.type === 'tool/result')
const contextEvents = log.filter(event => event.type === 'context/message')
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'a' },
{ kind: 'plugin', plugin: 'b' },
])
expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
})
})
describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => {
@@ -637,7 +665,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
const decision = await next()
if (decision.kind === 'accept') {
return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
return { kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } }] }
}
return decision
})

View File

@@ -35,17 +35,18 @@ tools:
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
- `ToolDefinition``ToolSchema` + `execute(args, exec: ToolRunContext): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`, including optional `envelope` and durable JSON `meta`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContexts?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent.
- `PostToolDecision``{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `PostToolDecision``{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch and yields an `isError` result, and an `ask` resolves through the approval seam first — only a grant dispatches (see `PreToolDecision` above). `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch and yields an `isError` result, and an `ask` resolves through the approval seam first — only a grant dispatches (see `PreToolDecision` above). `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContexts`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas
@@ -133,7 +134,7 @@ const bash = defineTool({
Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `<parent>:code:<n>`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode).
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `<parent>:code:<n>`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.

View File

@@ -206,12 +206,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
...exec.agent ? { agent: exec.agent } : {},
signal: runController.signal,
})
for (const context of result.additionalContexts ?? []) {
exec.deferContext(context)
}
const text = textOf(result.content)
// Sub-call `additionalContext` is deliberately DROPPED here: the
// loop's buffering (append after the step's tool/results) has no
// safe analogue from inside a running run_code — injecting now
// would break tool-call/result adjacency. Deferred until a real
// hook needs it through Code Mode.
exec.agent?.session.append('tool/code-dispatch', {
parentCallId: exec.callId,
subCallId,

View File

@@ -115,7 +115,7 @@ declare module 'cordis' {
/**
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
* accept it (optionally REPLACING the model-facing content, and/or attaching
* `additionalContext` for the next request) or block it with corrective
* `additionalContexts` for the next request) or block it with corrective
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
* `(exec, result, next)`: call `next()` to delegate to the default (accept
* unchanged), or return a {@link PostToolDecision} to override. Core tool
@@ -154,7 +154,7 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
@@ -209,6 +209,21 @@ export interface ToolExecution {
signal?: AbortSignal
}
/**
* Runtime context handed to a tool implementation after the registry has
* accepted a {@link ToolExecution}. A composite tool uses
* {@link deferContext} to ferry context produced by nested dispatches back to
* the outer result; the loop appends it only after the outer `tool/result`.
*/
export interface ToolRunContext extends ToolExecution {
/**
* Defer one nested-dispatch context until this tool's final result reaches
* the agent loop. Contexts retain their individual source, envelope, and
* metadata and are emitted in call order.
*/
deferContext(context: HookContext): void
}
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
@@ -240,17 +255,14 @@ export interface ToolExecutionResult {
*/
error?: ToolErrorInfo
/**
/**
* Extra model-facing context a `tools/post-execute` listener attached for the
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
* of this call's `content` — `content`/`feedback` shape the tool RESULT, but
* `additionalContext` is a SEPARATE `context/message`. A step can carry
* multiple tool calls, so the loop BUFFERS every call's `additionalContext`
* and appends them only AFTER all `tool/result`s for the step, keeping
* tool-call/result adjacency intact. Carried on the result purely to ferry it
* from `execute()` up to the loop's per-step buffer.
* Extra model-facing contexts deferred by a composite tool or attached by
* `tools/post-execute` listeners for the NEXT request. They are NOT part of
* this call's `content`: the loop buffers every context and appends them only
* AFTER all `tool/result`s for the step, preserving tool-call/result
* adjacency. The array preserves each context's source, envelope, metadata,
* and production order instead of flattening mixed plugin provenance.
*/
additionalContext?: HookContext
additionalContexts?: HookContext[]
/**
* The tool-private presentation payload from a successful `execute` (the object
* return form). Threaded onto the `tool/result` session event and back into
@@ -287,14 +299,14 @@ export type PreToolDecision =
* - `accept` keeps the call successful; optional `content` REPLACES the
* model-facing result (clean: `tool/result` is logged AFTER `execute()`
* returns, so a replaced result is the single source of truth for both derived
* history and UI). Optional `additionalContext` rides to the next request.
* history and UI). Optional `additionalContexts` ride to the next request.
* - `block` turns the call into an `isError` result whose content is the
* corrective `feedback` (the model is told the call was rejected and why),
* optionally also attaching `additionalContext`.
* optionally also attaching `additionalContexts`.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
/**
* Best-effort human-readable message from an arbitrary thrown value: Error
@@ -484,11 +496,18 @@ export class ToolRegistry extends Service {
* tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
* structured error. A thrown {@link HarnessError} surfaces its `{ name, code }`
* on the result.
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
* @param request - the call to run (name, parsed arguments, caller agent, signal).
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
async execute(request: ToolExecution): Promise<ToolExecutionResult> {
const deferredContexts: HookContext[] = []
const exec: ToolRunContext = {
...request,
deferContext(context): void {
deferredContexts.push(context)
},
}
try {
// --- Gate: tools/pre-execute. An `ask` resolves through the approval
// seam (or degrades) to allow/deny before the shared deny path. ---
@@ -531,7 +550,16 @@ export class ToolRegistry extends Service {
},
)
return await this.postExecute(exec, result)
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
? result
: {
...result,
additionalContexts: [
...deferredContexts,
...result.additionalContexts ?? [],
],
}
return await this.postExecute(exec, resultWithDeferredContexts)
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
// machinery) becomes an isError result, never a turn failure.
@@ -581,8 +609,11 @@ export class ToolRegistry extends Service {
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
* `content` when given), `block` turns it into an `isError` whose content is
* the corrective `feedback`. Either decision may attach `additionalContext`,
* which is ferried on the returned result for the loop's per-step buffer.
* the corrective `feedback`. Either decision may attach `additionalContexts`,
* which are ferried on the returned result for the loop's per-step buffer.
* Context deferred by the tool body survives an accepted result but is
* discarded when the outer call is blocked; a block exposes only context the
* blocking decision explicitly supplied.
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
*/
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
@@ -602,25 +633,32 @@ export class ToolRegistry extends Service {
isError: result.isError,
...result.error ? { error: result.error } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
...result.additionalContexts !== undefined
? { additionalContexts: [...result.additionalContexts] }
: {},
}
const decision = await this.ctx.waterfall(
this, 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
)
const additionalContext = decision.additionalContext
const decisionContexts = decision.additionalContexts ?? []
if (decision.kind === 'block') {
return {
callId: dispatched.callId,
content: decision.feedback,
isError: true,
...additionalContext ? { additionalContext } : {},
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
}
}
// accept: replace content if supplied, preserve the dispatched isError/error.
const additionalContexts = [
...dispatched.additionalContexts ?? [],
...decisionContexts,
]
return {
...dispatched,
...decision.content ? { content: decision.content } : {},
...additionalContext ? { additionalContext } : {},
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}
}

View File

@@ -20,7 +20,7 @@
*/
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
// ---------------------------------------------------------------------------
@@ -308,7 +308,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* content only) or a `{ content, meta }` object to also attach a tool-private
* presentation payload (see {@link ToolExecuteReturn}).
*/
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
/**
* Optional: how to present the PENDING state of one call in a UI (an editor
* tool-call card, a CLI log line). `args` is the typed, schema-validated
@@ -377,7 +377,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the

View File

@@ -307,27 +307,71 @@ describe('the run_code dispatch bridge', () => {
expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' })
})
it('suppresses sub-call additionalContext (deliberately; pinned)', async () => {
it('defers sub-call additionalContexts onto the outer run_code result', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
if (exec.name === 'echo') {
return Promise.resolve({
kind: 'accept' as const,
additionalContext: { content: [{ type: 'text' as const, text: 'context for the next request' }], source: { kind: 'plugin' as const, plugin: 'test' } },
additionalContexts: [{
content: [{ type: 'text' as const, text: `context for ${exec.callId}` }],
source: { kind: 'plugin' as const, plugin: 'test' },
envelope: 'raw' as const,
meta: { callId: exec.callId },
}],
})
}
return next()
})
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.echo!({ value: 'x' })
await request.bindings[0]!.functions.echo!({ value: 'y' })
return { logs: [], value: 'done' }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
// The sub-call's context has no safe outlet mid-run; the parent result
// must not carry it either.
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toEqual([
{
content: [{ type: 'text', text: 'context for call-1:code:1' }],
source: { kind: 'plugin', plugin: 'test' },
envelope: 'raw',
meta: { callId: 'call-1:code:1' },
},
{
content: [{ type: 'text', text: 'context for call-1:code:2' }],
source: { kind: 'plugin', plugin: 'test' },
envelope: 'raw',
meta: { callId: 'call-1:code:2' },
},
])
})
it('keeps sub-call contexts when run_code fails after the nested dispatch', async () => {
const { ctx, runtime } = await setup({ mode: 'both' })
registerEcho(ctx)
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
if (exec.name !== 'echo') return next()
return Promise.resolve({
kind: 'accept',
additionalContexts: [{
content: [{ type: 'text', text: 'nested context' }],
source: { kind: 'plugin', plugin: 'test' },
}],
})
})
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.echo!({ value: 'x' })
return { logs: [], error: { kind: 'exception', message: 'program failed later' } }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(true)
expect(result.additionalContexts).toEqual([{
content: [{ type: 'text', text: 'nested context' }],
source: { kind: 'plugin', plugin: 'test' },
}])
})
it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => {

View File

@@ -308,7 +308,7 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
})
it('a block decision can ALSO attach additionalContext', async () => {
it('a block decision can ALSO attach additionalContexts', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -316,24 +316,95 @@ describe('ToolRegistry', () => {
({
kind: 'block',
feedback: [{ type: 'text', text: 'rejected' }],
additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } },
additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'rejected' })
expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }])
})
it('a post-execute additionalContext rides on the result for the loop to buffer', async () => {
it('post-execute additionalContexts ride on the result for the loop to buffer', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } }))
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
})
it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'composite',
description: 'composite',
parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' }, envelope: 'raw' })
return [{ type: 'text', text: 'done' }]
},
}))
ctx.on('tools/execute', async (_exec, next) => {
const result = await next()
return {
...result,
additionalContexts: [
...result.additionalContexts ?? [],
{ content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' } },
],
}
})
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
const downstream = await next()
return {
...downstream,
additionalContexts: [
{ content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' } },
...downstream.additionalContexts ?? [],
],
}
})
const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} })
expect(result.additionalContexts?.map(context => context.source)).toEqual([
{ kind: 'plugin', plugin: 'nested-1' },
{ kind: 'plugin', plugin: 'nested-2' },
{ kind: 'plugin', plugin: 'wrapper' },
{ kind: 'plugin', plugin: 'post' },
])
expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
expect(result.additionalContexts?.[1]?.envelope).toBe('raw')
})
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'failing-composite',
description: 'failing composite',
parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' } })
throw new Error('outer failure')
},
}))
const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} })
expect(failed.isError).toBe(true)
expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }])
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
kind: 'block',
feedback: [{ type: 'text', text: 'blocked' }],
additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }],
}))
const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
expect(blocked.isError).toBe(true)
expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }])
})
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {

View File

@@ -30,7 +30,7 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de
## Reminder delivery
Reminders ride the post-execute decision's `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and folds its reminder onto the downstream decision (both variants — a blocked call still gets the nudge); when a downstream listener attached its own `additionalContext`, the fold concatenates content and carries the guard's `source` (a `HookContext` holds one `MessageSource`; `source.kind` is what framing depends on).
Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source, envelope, and metadata.
## Testing

View File

@@ -7,7 +7,7 @@
* through the `tools/post-execute` waterfall, count runs of consecutive calls
* to the same tool with identical canonicalized arguments, and at configured
* run lengths fold an escalating advisory reminder onto the decision's
* `additionalContext`. The loop appends that context as a logged
* `additionalContexts`. The loop appends that context as a logged
* `context/message` after the step's tool results, so the reminder is
* model-visible, source-attributed, and reconstructable from the session log
* with no new session event. Decision record:
@@ -168,16 +168,11 @@ function validateThresholds(values: number[]): number[] {
}
/**
* Concatenate the guard's reminder context with a downstream listener's
* optional one so folding drops neither. The merged block carries the guard's
* `source` — a `HookContext` holds one `MessageSource` and the seam cannot
* represent mixed provenance; the rendered `context/message` only
* distinguishes by `source.kind`, so a downstream plugin's text is still
* correctly framed as plugin context.
* Prepend the guard's reminder while preserving every downstream context's
* source, envelope, and metadata.
*/
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
if (!theirs) return ours
return { content: [...ours.content, ...theirs.content], source: ours.source }
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] {
return [ours, ...theirs ?? []]
}
/** One agent's consecutive-repeat chain: the last tracked call's identity key and its run length. */
@@ -237,19 +232,19 @@ export function apply(ctx: Context, config: Config): void {
// Observe-and-enrich, never veto: count first (state advances regardless of
// the downstream outcome), DELEGATE so a later listener can still block or
// replace, then fold the reminder onto whatever came back — additionalContext
// replace, then fold the reminder onto whatever came back — additionalContexts
// rides both decision variants, so a blocked call still gets the nudge.
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
const reminder = observe(exec)
const downstream = await next()
if (!reminder) return downstream
if (downstream.kind === 'block') {
return { kind: 'block', feedback: downstream.feedback, additionalContext: concatContext(reminder, downstream.additionalContext) }
return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(reminder, downstream.additionalContext),
additionalContexts: prependContext(reminder, downstream.additionalContexts),
}
})

View File

@@ -309,7 +309,7 @@ describe('fold onto the downstream decision', () => {
ctx.on('tools/post-execute', async () => ({
kind: 'block' as const,
feedback: [{ type: 'text' as const, text: 'nope' }],
additionalContext: { content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' } },
additionalContexts: [{ content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' } }],
}))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
@@ -322,14 +322,14 @@ describe('fold onto the downstream decision', () => {
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(2)
expect(found).toHaveLength(3)
// Call 1: below threshold — the downstream context passes through untouched.
expect(found[0]!.text).toBe('downstream-ctx')
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
// Call 2: reminder folded in front, single merged context, the guard's source.
// Call 2: reminder and downstream context retain separate provenance.
expect(found[1]!.text).toContain('repeating the exact same tool call')
expect(found[1]!.text).toContain('|downstream-ctx')
expect(found[1]!.source).toEqual(GUARD_SOURCE)
expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } })
// The block's feedback reached the tool result unchanged.
const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
expect(results.every(r => r.data.isError)).toBe(true)

View File

@@ -37,7 +37,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
| `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny``PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a later listener can still block/rewrite) |
| `PreToolUse` | `tools/pre-execute` (waterfall) | `deny``PreToolDecision.deny`; `ask``PreToolDecision.ask` |
| `PostToolUse` | `tools/post-execute` (waterfall) | `deny``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-calls context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) |
| `PostToolUse` | `tools/post-execute` (waterfall) | `deny``block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result |
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering |
| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child |
| `SubagentStop` | `subagent/end` (emit) | observe-only |

View File

@@ -222,9 +222,9 @@ export function apply(ctx: Context, config: Config): void {
}
/**
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
* call sites) with a downstream listener's optional one, so folding our
* additionalContext onto a delegated decision drops neither. The merged block
* Concatenate this bridge's prompt {@link HookContext} with a downstream
* prompt listener's optional one, so folding additionalContext drops neither.
* The merged block
* carries a single `source` — this bridge's — because a `HookContext` holds one
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
@@ -236,6 +236,11 @@ export function apply(ctx: Context, config: Config): void {
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
/** Prepend one post-tool context without flattening downstream provenance. */
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] {
return [ours, ...theirs ?? []]
}
// --- SessionStart: emit (cannot block). Inject any additionalContext into the
// agent. The matcher subject is the source.
// TODO(session-start-gating): `agent/session-start` is a SYNCHRONOUS emit and
@@ -293,19 +298,19 @@ export function apply(ctx: Context, config: Config): void {
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} }
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }
}
// Our hooks did not block. DELEGATE so a later listener can still block/replace,
// then fold our context onto its decision (a downstream block carries it too).
const downstream = await next()
if (!context) return downstream
if (downstream.kind === 'block') {
return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) }
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(context, downstream.additionalContext),
additionalContexts: prependContext(context, downstream.additionalContexts),
}
})

View File

@@ -522,6 +522,35 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const contexts = events(agent).filter(event => event.type === 'context/message')
expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-claude' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
// The bridge hook only adds context; a later post-execute listener blocks the
// result. The block wins AND carries the bridge context (concatContext on the

View File

@@ -43,7 +43,7 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
| `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
| `PreToolUse` | `tools/pre-execute` (waterfall) | `block``PreToolDecision.deny` (no `allow`/`ask`) |
| `PostToolUse` | `tools/post-execute` (waterfall) | `block``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-calls context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) |
| `PostToolUse` | `tools/post-execute` (waterfall) | `block``block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result |
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering |
A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers.

View File

@@ -177,9 +177,9 @@ export function apply(ctx: Context, config: Config): void {
}
/**
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
* call sites) with a downstream listener's optional one, so folding our
* additionalContext onto a delegated decision drops neither. The merged block
* Concatenate this bridge's prompt {@link HookContext} with a downstream
* prompt listener's optional one, so folding additionalContext drops neither.
* The merged block
* carries a single `source` — this bridge's — because a `HookContext` holds one
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
@@ -190,6 +190,11 @@ export function apply(ctx: Context, config: Config): void {
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
/** Prepend one post-tool context without flattening downstream provenance. */
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] {
return [ours, ...theirs ?? []]
}
// SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext.
// TODO(session-start-gating): a synchronous emit + detached `.then`, so the
// injected context is BEST-EFFORT — not guaranteed before the first turn reaches
@@ -235,19 +240,19 @@ export function apply(ctx: Context, config: Config): void {
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} }
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }
}
// Context alone is not a veto: DELEGATE, then fold our context onto the
// downstream decision (a downstream block carries it too).
const downstream = await next()
if (!context) return downstream
if (downstream.kind === 'block') {
return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) }
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(context, downstream.additionalContext),
additionalContexts: prependContext(context, downstream.additionalContexts),
}
})

View File

@@ -118,6 +118,33 @@ describe('hooks-codex coverage — decision mapping paths', () => {
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
const d = dir()
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const contexts = events(agent).filter(event => event.type === 'context/message')
expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
const d = dir()
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
@@ -395,7 +422,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
expect(result.isError).toBeFalsy()
expect(result.additionalContext?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true)
expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true)
})
it('when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)', async () => {

View File

@@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by one configured instruction candidate in each directory from the project root to `agent.session.header.cwd`. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached as `additionalContext`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.

View File

@@ -20,7 +20,6 @@ import {
} from './files.ts'
import {
baselineInstructionChanges,
concatContext,
dynamicInstructionContext,
name,
reconcileInstructionContext,
@@ -114,7 +113,7 @@ export function apply(ctx: Context, config: Config): void {
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(context, downstream.additionalContext),
additionalContexts: [context, ...downstream.additionalContexts ?? []],
}
})
}

View File

@@ -6,7 +6,7 @@
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { renderContextContent, type JsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { FileSystem } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
@@ -65,23 +65,6 @@ export function workspaceContextMessage(text: string): Message {
return { role: 'user', content: [{ type: 'text', text }] }
}
/**
* Preserve workspace state ownership while folding a downstream context contribution.
* @param ours - workspace raw context and structured metadata.
* @param theirs - optional downstream context with its own envelope semantics.
* @returns one workspace-owned context containing both model-visible contributions.
*/
export function concatContext(ours: WorkspaceHookContext, theirs: HookContext | undefined): WorkspaceHookContext {
if (theirs === undefined) return ours
return {
...ours,
content: [
...ours.content,
...renderContextContent(theirs.content, theirs.source, theirs.envelope),
],
}
}
function filePathFromExecution(exec: ToolExecution): string | undefined {
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined

View File

@@ -139,15 +139,22 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
}
function appendAdditionalContext(agent: Agent, result: { additionalContext?: HookContext }): number | undefined {
const context = result.additionalContext
if (context === undefined) return undefined
return agent.session.append('context/message', {
content: context.content,
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' }).seq
function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined {
return result.additionalContexts?.find(context =>
context.source.kind === 'plugin' && context.source.plugin === 'workspace-context')
}
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined {
let lastSeq: number | undefined
for (const context of result.additionalContexts ?? []) {
lastSeq = agent.session.append('context/message', {
content: context.content,
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' }).seq
}
return lastSeq
}
const composedPrefixes = new WeakMap<object, Message[]>()
@@ -763,7 +770,7 @@ describe('workspace context request injection', () => {
kind: 'block',
feedback: [{ type: 'text', text: 'blocked by policy' }],
})
expect(blocked.additionalContext).toBeUndefined()
expect(blocked.additionalContexts).toBeUndefined()
// The same read, when the downstream accepts, DOES surface the nested
// instructions — proving the block branch above is what suppressed them,
@@ -772,8 +779,8 @@ describe('workspace context request injection', () => {
kind: 'accept' as const,
}))
expect(accepted.kind).toBe('accept')
expect(accepted.additionalContext?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(blocksText(accepted.additionalContext?.content)).toContain('nested package rule')
expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
@@ -891,11 +898,11 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(result.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(result)?.meta).toMatchObject({
changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }],
})
expect(blocksText(result.additionalContext?.content)).toContain('Updated instructions from: AGENTS.md')
expect(blocksText(result.additionalContext?.content)).toContain('new root rule with more detail')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('new root rule with more detail')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -919,10 +926,10 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(result.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(result)?.meta).toMatchObject({
changes: [{ action: 'remove', scope: '.', path: 'AGENTS.md' }],
})
expect(blocksText(result.additionalContext?.content)).toContain('Instructions removed: AGENTS.md')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -945,7 +952,7 @@ describe('workspace context request injection', () => {
})
expect(derivedText(agent).match(/shared root and global rule/g)).toHaveLength(1)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
}
@@ -1361,9 +1368,9 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(result.additionalContext?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(result.additionalContext?.envelope).toBe('raw')
expect(result.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.envelope).toBe('raw')
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
version: 1,
changes: [{
@@ -1372,7 +1379,7 @@ describe('dynamic nested workspace context injection', () => {
path: 'pkg/AGENTS.md',
}],
})
const meta = result.additionalContext?.meta
const meta = workspaceContextOf(result)?.meta
const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
? meta.changes[0]
: undefined
@@ -1380,7 +1387,7 @@ describe('dynamic nested workspace context injection', () => {
? firstChange.digest
: undefined
expect(changeDigest).toMatch(/^[a-f0-9]{40}$/)
const text = blocksText(result.additionalContext?.content)
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toBe([
'<system-reminder>',
'Additional instructions from: pkg/AGENTS.md',
@@ -1420,7 +1427,7 @@ describe('dynamic nested workspace context injection', () => {
agent: stubAgent(root),
})
const text = blocksText(result.additionalContext?.content)
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md')
expect(text).toContain('local package rule')
expect(text).not.toContain('native package rule')
@@ -1454,8 +1461,8 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(first.additionalContext).toBeDefined()
expect(second.additionalContext).toBeUndefined()
expect(first.additionalContexts).toBeDefined()
expect(second.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1476,17 +1483,17 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail')
const changed = await ctx.tools.execute({
callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(changed.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(changed)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }],
})
expect(blocksText(changed.additionalContext?.content)).toBe([
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
'<system-reminder>',
'Updated instructions from: pkg/AGENTS.md',
'',
@@ -1516,25 +1523,25 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const changed = await ctx.tools.execute({
callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, changed)
appendAdditionalContexts(agent, changed)
const unchanged = await ctx.tools.execute({
callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(changed.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(changed)?.meta).toMatchObject({
changes: [{
action: 'replace', scope: 'pkg', path: 'pkg/CLAUDE.md', previousPath: 'pkg/AGENTS.md',
}],
})
expect(blocksText(changed.additionalContext?.content)).toContain('Updated instructions from: pkg/CLAUDE.md')
expect(blocksText(changed.additionalContext?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.')
expect(blocksText(changed.additionalContext?.content)).toContain('fallback package rule')
expect(unchanged.additionalContext).toBeUndefined()
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('Updated instructions from: pkg/CLAUDE.md')
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.')
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule')
expect(unchanged.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1555,18 +1562,18 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const removed = await ctx.tools.execute({
callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(removed.additionalContext?.meta).toEqual({
expect(workspaceContextOf(removed)?.meta).toEqual({
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'remove', scope: 'pkg', path: 'pkg/AGENTS.md' }],
})
expect(blocksText(removed.additionalContext?.content)).toBe([
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
'<system-reminder>',
'Instructions removed: pkg/AGENTS.md',
'',
@@ -1593,23 +1600,23 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const removed = await ctx.tools.execute({
callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, removed)
appendAdditionalContexts(agent, removed)
await write(join(root, 'pkg/AGENTS.md'), 'restored package rule')
const restored = await ctx.tools.execute({
callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(restored.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(restored)?.meta).toMatchObject({
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
})
expect(blocksText(restored.additionalContext?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
expect(blocksText(restored.additionalContext?.content)).toContain('restored package rule')
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1635,14 +1642,14 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
const duringFailure = await ctx.tools.execute({
callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(first.additionalContext).toBeDefined()
expect(duringFailure.additionalContext).toBeUndefined()
expect(first.additionalContexts).toBeDefined()
expect(duringFailure.additionalContexts).toBeUndefined()
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
@@ -1666,7 +1673,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/deep/file.txt' },
agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
const resumed = {
...agent,
session: new Session(agent.session.id, [...agent.session.events], agent.session.header),
@@ -1679,8 +1686,8 @@ describe('dynamic nested workspace context injection', () => {
agent: resumed,
})
expect(first.additionalContext).toBeDefined()
expect(afterResume.additionalContext).toBeUndefined()
expect(first.additionalContexts).toBeDefined()
expect(afterResume.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1700,7 +1707,7 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original,
})
appendAdditionalContext(original, first)
appendAdditionalContexts(original, first)
await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume')
const resumed = stubAgent(root, [...original.session.events])
@@ -1733,7 +1740,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/deep/file.txt' },
agent,
})
const contextSeq = appendAdditionalContext(agent, first)!
const contextSeq = appendAdditionalContexts(agent, first)!
const visibleBeforeCompact = await ctx.tools.execute({
callId: CallId('read-while-visible'),
name: 'read',
@@ -1753,10 +1760,10 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(first.additionalContext).toBeDefined()
expect(visibleBeforeCompact.additionalContext).toBeUndefined()
expect(afterCompact.additionalContext).toBeDefined()
expect(blocksText(afterCompact.additionalContext?.content)).toContain('nested package rule')
expect(first.additionalContexts).toBeDefined()
expect(visibleBeforeCompact.additionalContexts).toBeUndefined()
expect(afterCompact.additionalContexts).toBeDefined()
expect(blocksText(workspaceContextOf(afterCompact)?.content)).toContain('nested package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1781,7 +1788,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/file.txt' },
agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
const second = await ctx.tools.execute({
callId: CallId('read-subtree'),
@@ -1790,8 +1797,8 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(blocksText(first.additionalContext?.content)).toContain('package note')
expect(blocksText(second.additionalContext?.content)).toContain('subtree rule')
expect(blocksText(workspaceContextOf(first)?.content)).toContain('package note')
expect(blocksText(workspaceContextOf(second)?.content)).toContain('subtree rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1816,7 +1823,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/sub/file.txt' },
agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
const second = await ctx.tools.execute({
callId: CallId('read-parent-after-omit'),
@@ -1825,11 +1832,11 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
const firstText = blocksText(first.additionalContext?.content)
const firstText = blocksText(workspaceContextOf(first)?.content)
expect(firstText).toContain('omitted pkg/AGENTS.md')
expect(firstText).not.toContain('## pkg/AGENTS.md')
expect(firstText).toContain('subtree rule')
expect(blocksText(second.additionalContext?.content)).toContain('parent rule')
expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1886,7 +1893,7 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(blocksText(result.additionalContext?.content)).toContain('nested package rule')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1918,8 +1925,8 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(rootResult.additionalContext).toBeUndefined()
expect(blocksText(absoluteResult.additionalContext?.content)).toContain('nested package rule')
expect(rootResult.additionalContexts).toBeUndefined()
expect(blocksText(workspaceContextOf(absoluteResult)?.content)).toContain('nested package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1982,7 +1989,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
await chmod(nested, 0o600)
} finally {
await rm(root, { recursive: true, force: true })
@@ -1990,7 +1997,7 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('folds nested instruction context with downstream post-execute content and context', async () => {
it('preserves nested and downstream post-execute contexts as separate entries', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -2002,10 +2009,10 @@ describe('dynamic nested workspace context injection', () => {
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'downstream replacement' }],
additionalContext: {
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream context' }],
source: { kind: 'plugin' as const, plugin: 'downstream' },
},
}],
}))
const result = await ctx.tools.execute({
@@ -2016,17 +2023,22 @@ describe('dynamic nested workspace context injection', () => {
})
expect(blocksText(result.content)).toBe('downstream replacement')
expect(result.additionalContext?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(result.additionalContext?.envelope).toBe('raw')
expect(result.additionalContext?.meta).toMatchObject({
expect(result.additionalContexts).toHaveLength(2)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.envelope).toBe('raw')
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
})
expect(blocksText(result.additionalContext?.content)).toContain('nested package rule')
expect(blocksText(result.additionalContext?.content)).toContain('downstream context')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')
expect(result.additionalContexts?.[1]).toEqual({
content: [{ type: 'text', text: 'downstream context' }],
source: { kind: 'plugin', plugin: 'downstream' },
})
const agent = stubAgent(root)
appendAdditionalContext(agent, result)
expect(blocksText(agent.session.deriveMessages()[0]?.content)).toContain('<context source="plugin">\ndownstream context\n</context>')
appendAdditionalContexts(agent, result)
expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('<context source="plugin">\ndownstream context\n</context>')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2058,7 +2070,7 @@ describe('dynamic nested workspace context injection', () => {
// should reach the model, and the block feedback must survive unchanged.
expect(result.isError).toBe(true)
expect(blocksText(result.content)).toBe('blocked downstream')
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2122,7 +2134,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2146,7 +2158,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(true)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2172,7 +2184,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })