Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.md
#	docs/config-catalog.md
#	docs/cookbook/adding-a-tool.i18n.yaml
#	docs/cookbook/adding-a-tool.md
#	docs/cookbook/adding-a-tool.zh.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/tools.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/bash/tool-bash/src/index.ts
#	packages/core/agent-loop/src/tool-calls.ts
#	packages/core/agent-loop/tests/cancel.spec.ts
#	packages/core/agent-loop/tests/contract-regressions.spec.ts
#	packages/core/agent-loop/tests/tool-calls.spec.ts
#	packages/core/tools/README.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/tests/code-mode.spec.ts
#	packages/core/tools/tests/tools.spec.ts
#	packages/fs/tool-fs-search/tests/integration.spec.ts
#	packages/fs/tool-fs-search/tests/tools.spec.ts
#	packages/fs/tool-fs/tests/integration.spec.ts
#	packages/mcp/mcp-client/src/tools.ts
#	packages/timeout/timeout-policy/tests/timeout-policy.spec.ts
#	packages/web/tool-web/tests/integration.spec.ts
#	packages/web/tool-web/tests/tool-web.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-21 23:39:03 +08:00
194 changed files with 3387 additions and 1222 deletions

View File

@@ -106,7 +106,12 @@ export function apply(ctx: Context, config: Config): void {
point: string,
matchQuery: string,
payload: unknown,
opts: { agent?: Agent; turn?: number; signal?: AbortSignal; plainStdoutAsContext?: boolean },
opts: {
agent?: Agent
turn?: number
readonly signal: AbortSignal
plainStdoutAsContext?: boolean
},
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
@@ -129,7 +134,7 @@ export function apply(ctx: Context, config: Config): void {
payload,
defaultTimeoutMs,
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
signal: opts.signal,
trailingNewline: false, // Codex writes stdin without a trailing newline.
// Discard a `hookSpecificOutput` block naming a different event.
expectedEventName: point,
@@ -183,9 +188,9 @@ export function apply(ctx: Context, config: Config): void {
})
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
ctx.on('agent/prompt-submit', async (agent, content, _source, signal, next): Promise<PromptDecision> => {
const turn = lastTurn(agent)
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(ctx, agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(ctx, agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true, signal })
/* jscpd:ignore-start */
if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
// Context alone is not a veto: DELEGATE so a later prompt-submit listener can
@@ -203,7 +208,7 @@ export function apply(ctx: Context, config: Config): void {
// PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
/* jscpd:ignore-end */
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
@@ -213,7 +218,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
/* jscpd:ignore-start */
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, 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 ? { additionalContexts: [context] } : {} }
@@ -235,8 +240,8 @@ export function apply(ctx: Context, config: Config): void {
// TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
// avoid continuing the same turn indefinitely. It is always false here, so an
// unconditionally blocking hook force-continues every step until it self-limits.
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
ctx.on('agent/turn-continuation', async (agent, turn, _default, signal, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal })
/* jscpd:ignore-end */
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation; a block with no reason (exit 2,

View File

@@ -105,6 +105,32 @@ describe('hooks-codex bridge', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
})
it('turn cancellation aborts and reaps a running UserPromptSubmit hook before idle', async () => {
const dir = configDir()
const pidFile = join(dir, 'pid')
const marker = join(dir, 'started')
const slow = script(dir, 'slow-prompt.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: slow }] }] })
const adapter = new MockAdapter([textResponse('must not run')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'cancel the hook' }])
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
await idle
expect(() => process.kill(pid, 0)).toThrow()
expect(adapter.requests).toHaveLength(0)
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'aborted' } },
})
expect(events(agent).some(event => event.type === 'hook/result' && event.data.point === 'UserPromptSubmit')).toBe(true)
})
it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
const dir = configDir()
const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')

View File

@@ -13,6 +13,8 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const testToolSignal = new AbortController().signal
const dirs: string[] = []
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d }
@@ -451,7 +453,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
expect(ran).toBe(false) // denied
expect(result.isError).toBe(true)
})
@@ -462,7 +464,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
expect(result.isError).toBeFalsy()
expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true)
})