Merge origin/master into worktree/explicit-turn-signal

This commit is contained in:
Yichen Jiang
2026-07-20 21:38:49 +08:00
1322 changed files with 47895 additions and 20257 deletions

View File

@@ -10,7 +10,7 @@ This bridge implements a deliberate subset of Codex's current hook protocol:
- **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell).
- **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input.
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md)).
## Config
@@ -60,15 +60,31 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }`
### Hook-provided context
**What the model sees**: `SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering.
#### What the model sees
**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction.
`SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering.
#### Token effect
No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Blocked prompt or tool outcome
**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced.
#### What the model sees
**Token effect**: Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced.
#### Token effect
Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
#### KV Cache effect
A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it.
## Known Limitations and Deferred Work

View File

@@ -5,7 +5,7 @@
* or command substitution, and no pre-tool approval or rewrite path; only
* blocking decisions are honored. Shared execution and parsing live in
* `dsh-hook-protocol`; see the
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
* [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
* @module @deepseek-ai/dsh-hooks-codex
*/

View File

@@ -4,10 +4,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
@@ -47,14 +47,14 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
/** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
@@ -76,7 +76,7 @@ describe('hooks-codex bridge', () => {
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run ls' }])
await waitForIdle(ctx, agent)
@@ -97,7 +97,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -112,7 +112,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([textResponse('fine')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
@@ -122,7 +122,7 @@ describe('hooks-codex bridge', () => {
const dir = configDir() // no hooks.json written
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
@@ -142,7 +142,7 @@ describe('hooks-codex bridge', () => {
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
await fiber.dispose()
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
@@ -165,7 +165,7 @@ describe('hooks-codex bridge', () => {
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
const warn = vi.fn()
ctx.logger.warn = warn as never
ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start
ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await fiber.dispose()

View File

@@ -3,11 +3,11 @@ import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
@@ -34,10 +34,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
@@ -62,7 +62,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('transcript'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
return {
@@ -81,7 +81,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
const adapter = new MockAdapter([textResponse('no')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
const te = events(agent).findLast(e => e.type === 'turn/end')
@@ -93,7 +93,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
})
@@ -106,7 +106,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
@@ -125,11 +125,10 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
additionalContexts: [{
content: [{ type: 'text' as const, text: 'from-downstream' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const req = JSON.stringify(adapter.requests[0]!.messages)
expect(req).toContain('from-bridge')
@@ -140,7 +139,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
{ 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' })
})
})
@@ -153,7 +151,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
@@ -171,11 +169,10 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const contexts = events(agent).filter(event => event.type === 'context/message')
@@ -183,7 +180,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
{ 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' })
})
@@ -194,7 +190,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
@@ -207,7 +203,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
@@ -220,7 +216,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
@@ -233,7 +229,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
})
@@ -247,7 +243,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
})
@@ -258,7 +254,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
@@ -271,7 +267,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
@@ -294,7 +290,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
@@ -317,7 +313,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
@@ -330,7 +326,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true)
})
@@ -344,7 +340,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
@@ -356,7 +352,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const warn = vi.fn(); ctx.logger.warn = warn as never
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject = (() => { throw new Error('inject boom') })
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
@@ -371,7 +367,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true)
})
@@ -384,7 +380,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false)
@@ -400,7 +396,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
@@ -413,7 +409,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
@@ -425,7 +421,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const r = events(agent).find(e => e.type === 'tool/result')
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
@@ -442,7 +438,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
expect(payload.tool_input.command).toBe('')
@@ -478,7 +474,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
@@ -494,7 +490,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] })
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
@@ -507,7 +503,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
})
@@ -521,7 +517,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => existsSync(marker)) // the exit-2 hook has finished
expect(events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false)
@@ -535,7 +531,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale')
@@ -546,7 +542,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
@@ -560,7 +556,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
})
@@ -575,7 +571,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } }
expect(payload.tool_name).toBe('shell')
@@ -591,7 +587,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(false) // the matcher fired → the hook denied the tool
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true)
@@ -603,7 +599,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const warn = vi.fn(); ctx.logger.warn = warn as never
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
@@ -626,9 +622,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.llm.registerAdapter(['mock'], adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
await waitForIdle(ctx, handle.agent)
expect(existsSync(marker)).toBe(true)
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
await handle.dispose()