Fix Code Mode workspace context propagation
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user