Fix Code Mode workspace context propagation
This commit is contained in:
@@ -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}',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-call’s 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 |
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-call’s 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.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 ?? []],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user