Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop
# Conflicts: # docs/event-producer-consumer.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/harness.ts # examples/coding-agent/tests/resume.e2e.ts # examples/cordis-agent/tests/harness.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts
This commit is contained in:
@@ -22,6 +22,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/stub',
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
|
||||
@@ -35,9 +35,9 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
|
||||
| CC hook | Harness seam | Mapping |
|
||||
|---|---|---|
|
||||
| `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) |
|
||||
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` (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 a live in-process child; a remote child has no local injection target |
|
||||
| `SubagentStop` | `subagent/end` (emit) | observe-only |
|
||||
@@ -46,6 +46,8 @@ The three emit points run detached — no seam awaits a `SessionStart`/`Subagent
|
||||
|
||||
The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note).
|
||||
|
||||
Every agent-scoped stdin payload carries `session_id` and string-shaped `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `''`. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn.
|
||||
|
||||
## Context source
|
||||
|
||||
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself.
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@deepseek-ai/dsh-hook-protocol": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -36,13 +37,15 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
appendHookInvoked,
|
||||
@@ -188,17 +189,16 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return { content, source: PLUGIN_SOURCE }
|
||||
}
|
||||
|
||||
/** Merge hook context while retaining this bridge's plugin-level source. */
|
||||
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
|
||||
if (!theirs) return ours
|
||||
return { content: [...ours.content, ...theirs.content], source: ours.source }
|
||||
/** Prepend one context without flattening downstream provenance or metadata. */
|
||||
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] {
|
||||
return [ours, ...theirs ?? []]
|
||||
}
|
||||
|
||||
// SessionStart injects context when its detached hook resolves; a slow hook
|
||||
// may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal })
|
||||
detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
if (context) agent.inject(context.content, { source: context.source })
|
||||
@@ -212,7 +212,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// matcher subject (CC ignores matchers for this event). ---
|
||||
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
|
||||
const turn = lastTurn(agent)
|
||||
const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn })
|
||||
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn })
|
||||
if (merged.decision === 'deny') {
|
||||
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
|
||||
}
|
||||
@@ -224,14 +224,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return {
|
||||
kind: 'allow',
|
||||
...downstream.content !== undefined ? { content: downstream.content } : {},
|
||||
additionalContext: concatContext(ours, downstream.additionalContext),
|
||||
additionalContexts: prependContext(ours, downstream.additionalContexts),
|
||||
}
|
||||
})
|
||||
|
||||
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
const turn = lastTurn(exec.agent)
|
||||
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
|
||||
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
|
||||
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
|
||||
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
|
||||
return next()
|
||||
@@ -240,29 +240,29 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---
|
||||
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
|
||||
const turn = lastTurn(exec.agent)
|
||||
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
|
||||
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, 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),
|
||||
}
|
||||
})
|
||||
|
||||
// A blocking Stop hook forces continuation with its reason.
|
||||
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
|
||||
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
|
||||
const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn })
|
||||
const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn })
|
||||
if (merged.decision === 'deny') {
|
||||
// A blocking Stop hook forces continuation.
|
||||
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
||||
@@ -275,7 +275,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// use the live child's workspace and the generic agent-type matcher subject.
|
||||
ctx.on('subagent/start', (info) => {
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
||||
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
if (context && child) child.inject(context.content, { source: context.source })
|
||||
@@ -287,7 +287,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the
|
||||
// child's cwd, not the server default.
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
|
||||
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -317,28 +317,31 @@ function blocksToText(content: ContentBlock[]): string {
|
||||
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
function base(agent: Agent | undefined, event: string): Record<string, unknown> {
|
||||
function base(ctx: Context, agent: Agent | undefined, event: string): Record<string, unknown> {
|
||||
return {
|
||||
session_id: agent?.session.header.id ?? '',
|
||||
transcript_path: agent === undefined
|
||||
? ''
|
||||
: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? '',
|
||||
cwd: agent?.session.header.cwd ?? process.cwd(),
|
||||
hook_event_name: event,
|
||||
}
|
||||
}
|
||||
|
||||
function sessionStartPayload(agent: Agent, source: string): Record<string, unknown> {
|
||||
return { ...base(agent, 'SessionStart'), source }
|
||||
function sessionStartPayload(ctx: Context, agent: Agent, source: string): Record<string, unknown> {
|
||||
return { ...base(ctx, agent, 'SessionStart'), source }
|
||||
}
|
||||
function promptPayload(agent: Agent, content: ContentBlock[]): Record<string, unknown> {
|
||||
return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) }
|
||||
function promptPayload(ctx: Context, agent: Agent, content: ContentBlock[]): Record<string, unknown> {
|
||||
return { ...base(ctx, agent, 'UserPromptSubmit'), prompt: blocksToText(content) }
|
||||
}
|
||||
function preToolPayload(exec: ToolExecution): Record<string, unknown> {
|
||||
return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }
|
||||
function preToolPayload(ctx: Context, exec: ToolExecution): Record<string, unknown> {
|
||||
return { ...base(ctx, exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }
|
||||
}
|
||||
function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record<string, unknown> {
|
||||
return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
|
||||
function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): Record<string, unknown> {
|
||||
return { ...base(ctx, exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
|
||||
}
|
||||
function stopPayload(agent: Agent): Record<string, unknown> {
|
||||
return { ...base(agent, 'Stop'), stop_hook_active: false }
|
||||
function stopPayload(ctx: Context, agent: Agent): Record<string, unknown> {
|
||||
return { ...base(ctx, agent, 'Stop'), stop_hook_active: false }
|
||||
}
|
||||
/**
|
||||
* Build a SubagentStart/SubagentStop payload from the CC base (the child's
|
||||
@@ -346,9 +349,9 @@ function stopPayload(agent: Agent): Record<string, unknown> {
|
||||
* fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active`
|
||||
* is present on SubagentStop only (the loop-guard flag, always false this cut).
|
||||
*/
|
||||
function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record<string, unknown> {
|
||||
function subagentPayload(ctx: Context, event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record<string, unknown> {
|
||||
return {
|
||||
...base(child, event),
|
||||
...base(ctx, child, event),
|
||||
agent_id: info.id,
|
||||
agent_type: SUBAGENT_TYPE,
|
||||
...event === 'SubagentStop' ? { stop_hook_active: false } : {},
|
||||
|
||||
@@ -4,13 +4,11 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
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 HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
@@ -46,11 +44,7 @@ async function harness(configDir: string, adapter: MockAdapter): Promise<Context
|
||||
/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
|
||||
async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
|
||||
@@ -96,7 +90,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do something' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -119,7 +113,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -144,7 +138,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
|
||||
const ctx = await harness(dir, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'use danger' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -167,7 +161,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
|
||||
const ctx = await harness(dir, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'use safe' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -189,7 +183,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -210,7 +204,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -234,7 +228,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
||||
const ctx = await harness(dir, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -258,7 +252,7 @@ describe('hooks-claude bridge — SessionStart', () => {
|
||||
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// session-start fires async (detached .then → agent.inject); wait for the
|
||||
// injected context/message to actually land before sending, rather than a
|
||||
// fixed sleep that flakes under load.
|
||||
@@ -351,16 +345,12 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
it('a missing config file registers no hooks and does not crash the loop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// The turn ran normally — no hooks, no crash.
|
||||
@@ -376,17 +366,13 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
|
||||
await fiber.dispose()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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
|
||||
|
||||
740
packages/hooks/hooks-claude/tests/coverage-cases.ts
Normal file
740
packages/hooks/hooks-claude/tests/coverage-cases.ts
Normal file
@@ -0,0 +1,740 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
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 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 HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent
|
||||
* fallbacks, contextFrom-empty, and the detached-listener catch handlers. */
|
||||
|
||||
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-hc-cov-')); dirs.push(d); return d }
|
||||
function sh(d: string, name: string, body: string): string {
|
||||
const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
|
||||
}
|
||||
function hooks(d: string, h: unknown): string {
|
||||
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
|
||||
}
|
||||
|
||||
type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number; sessionRoot?: string }
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath, ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
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: 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> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
export type CoverageGroup = 'config' | 'stop' | 'context' | 'edge-paths'
|
||||
|
||||
/** Register independently schedulable slices of the hooks-claude coverage matrix. */
|
||||
export function defineCoverageCases(group: CoverageGroup): void {
|
||||
if (group === 'config') describe('hooks-claude coverage — config option arms + substitution + skip warning', () => {
|
||||
it('uses the persistence locator for transcript_path and an empty string without one', async () => {
|
||||
async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string }; expected: string | undefined }> {
|
||||
const d = dir()
|
||||
const cap = join(d, 'payload')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
|
||||
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(SessionId('transcript'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
return {
|
||||
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string },
|
||||
expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path,
|
||||
}
|
||||
}
|
||||
|
||||
const located = await capture(dir())
|
||||
expect(located.payload.transcript_path).toBe(located.expected)
|
||||
expect((await capture()).payload.transcript_path).toBe('')
|
||||
}, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
|
||||
|
||||
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
|
||||
const d = dir()
|
||||
// ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker.
|
||||
const marker = join(d, 'ran')
|
||||
sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
const path = hooks(d, {
|
||||
PreToolUse: [{ hooks: [
|
||||
{ type: 'prompt', prompt: 'skipme' }, // skipped → warn loop
|
||||
{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted
|
||||
] }],
|
||||
})
|
||||
const warn = vi.fn()
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
|
||||
ctx.logger.warn = warn as never
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
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) // substituted command ran
|
||||
})
|
||||
|
||||
it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const warn = vi.fn()
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.logger.warn = warn as never
|
||||
let sawArgs: unknown
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// updatedInput is NOT honored — the tool ran with the ORIGINAL args.
|
||||
expect((sawArgs as { command?: string }).command).toBe('original')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput'))
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'config') describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => {
|
||||
it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ran')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// The prompt proceeded unchanged; no context/message injected.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
|
||||
const { CallId } = await import('@deepseek-ai/dsh-llm')
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
|
||||
expect(ran).toBe(false)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('a long stderr is truncated in the hook/result summary', async () => {
|
||||
const d = dir()
|
||||
// Emit >500 chars of stderr then exit 2.
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ 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' }] } }))
|
||||
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)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
|
||||
const d = dir()
|
||||
const path = hooks(d, {})
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
const adapter = new MockAdapter([])
|
||||
await expect(harness(path, adapter, { stderrSummaryMaxChars: bad }))
|
||||
.rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
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) + '…')
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => {
|
||||
it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`)
|
||||
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(path, adapter)
|
||||
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)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
|
||||
})
|
||||
|
||||
it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
|
||||
// A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces
|
||||
// continuation; the script self-limits to one block to avoid a loop.
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`)
|
||||
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// A second model request ran → the empty-reason block forced continuation.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
// The steering carried the fallback reason (no stderr to use).
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
|
||||
})
|
||||
|
||||
it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n')
|
||||
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
// Register a fake child agent under the id the event carries.
|
||||
const injected: string[] = []
|
||||
const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') })
|
||||
await waitFor(() => injected.includes('child guidance'))
|
||||
expect(injected).toContain('child guidance')
|
||||
})
|
||||
|
||||
it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => {
|
||||
const d = dir()
|
||||
// A hook command that does not exist makes runHook resolve a non-blocking
|
||||
// error (not a throw), so to hit the .catch we make the .then throw: register
|
||||
// a child whose inject throws for SubagentStart.
|
||||
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n')
|
||||
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') })
|
||||
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude coverage — default reasons + sparse payloads', () => {
|
||||
it('PreToolUse deny with EMPTY stderr uses the default reason', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr
|
||||
const path = hooks(d, { PreToolUse: [{ 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: 'x' }] } }))
|
||||
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.includes('blocked by PreToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\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' }] } }))
|
||||
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.includes('blocked by PostToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => {
|
||||
const d = dir()
|
||||
// The agents registry has no entry for the id, so the child lookup yields
|
||||
// undefined and the payload falls back to base(undefined) — assert the
|
||||
// observe-only SubagentStop run still executes the hook without crashing.
|
||||
const marker = join(d, 'stopran')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' })
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — more default/sparse arms', () => {
|
||||
it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('no')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook')
|
||||
})
|
||||
|
||||
it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// ask (no reason) → degrades to deny with the registry's generic message.
|
||||
expect(ran).toBe(false)
|
||||
expect(events(agent).some(e => e.type === 'tool/result' && e.data.isError)).toBe(true)
|
||||
})
|
||||
|
||||
it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { PreToolUse: [{ 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' }] } }))
|
||||
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)
|
||||
expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) with only configPath runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so
|
||||
// the bridge must run on the raw minimal config (the per-hook timeout is
|
||||
// the protocol lib's reference default, not a config knob).
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
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)
|
||||
})
|
||||
|
||||
it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => {
|
||||
const d = dir()
|
||||
// `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not
|
||||
// 2 → no decision), so the tool proceeds; the hook/result records exit 127.
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
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)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127)
|
||||
})
|
||||
|
||||
it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\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' }] } }))
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'context') describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => {
|
||||
it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
||||
// The seams cannot yet honor `continue:false` as a hard halt. The log must still record the
|
||||
// stop decision while execution and the turn continue normally.
|
||||
const d = dir()
|
||||
const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
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
|
||||
expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion
|
||||
})
|
||||
|
||||
it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\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' }] } }))
|
||||
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)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
|
||||
// additionalContext also injected (the block + context arm).
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => {
|
||||
// The block's hookEventName (UserPromptSubmit) mismatches the firing event
|
||||
// (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs.
|
||||
const d = dir()
|
||||
const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
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) // the mismatched deny was discarded → the tool ran
|
||||
})
|
||||
|
||||
it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => {
|
||||
// The default ACP wiring sets no projectDir. A stock CC hook that references
|
||||
// $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace,
|
||||
// not an empty string. The hook echoes the var as additionalContext.
|
||||
const d = dir()
|
||||
const workspace = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ran')])
|
||||
const ctx = await harness(path, adapter) // NB: no projectDir
|
||||
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(events(handle.agent).some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// A context-only hook delegates with `next()` and folds its context, so a downstream policy
|
||||
// listener can still veto the prompt.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(path, adapter)
|
||||
// A later listener that blocks every prompt (registered AFTER the bridge).
|
||||
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// the downstream block won: the model was never called, no user/message was
|
||||
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
|
||||
})
|
||||
|
||||
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
|
||||
// Both the bridge hook and a later prompt-submit listener attach context; the
|
||||
// request must see both as separately sourced durable events.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.on('agent/prompt-submit', async () => ({
|
||||
kind: 'allow' as const,
|
||||
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
|
||||
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(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')
|
||||
expect(req).toContain('from-downstream')
|
||||
expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved
|
||||
// the original prompt was replaced by the downstream rewrite
|
||||
const userMsg = events(agent).find(e => e.type === 'user/message')
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
|
||||
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 ACCEPT that replaces content', async () => {
|
||||
// The bridge hook adds context; a later post-execute listener accepts with a
|
||||
// content rewrite. Both the rewrite and the bridge context survive.
|
||||
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, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
|
||||
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)
|
||||
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(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')
|
||||
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
|
||||
// block arm).
|
||||
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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
|
||||
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)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
|
||||
// the bridge's context still landed (folded onto the block)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — executor reject + no-open-turn', () => {
|
||||
it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
// Force the executor to reject (an infrastructure fault) so runHook's catch
|
||||
// yields a HookOutput with exitCode undefined → the `exitCode` spread false arm.
|
||||
const bash = ctx.bash
|
||||
bash.run = (() => Promise.reject(new Error('executor down')))
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
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)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — detached-listener catch handlers', () => {
|
||||
it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// Make inject throw, forcing the SessionStart .catch path.
|
||||
const original = agent.inject.bind(agent)
|
||||
let threw = false
|
||||
agent.inject = (() => { threw = true; throw new Error('inject boom') })
|
||||
await waitFor(() => threw)
|
||||
expect(threw).toBe(true)
|
||||
agent.inject = original
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'stop') describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => {
|
||||
it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
|
||||
// The server launch directory and session cwd deliberately differ. The marker proves the
|
||||
// bridge passes `session/new.cwd` instead of falling back to the executor default.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
// The hook is invoked with cwd = session dir, so a relative marker path lands there.
|
||||
hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the session cwd).
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
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)
|
||||
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const where = readFileSync(marker, 'utf8').trim()
|
||||
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
|
||||
expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
|
||||
// `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint`
|
||||
// receives that agent and runs in the child's cwd rather than the executor default.
|
||||
const serverDir = dir()
|
||||
const childDir = dir()
|
||||
const marker = join(childDir, 'stopwhere')
|
||||
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] })
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
|
||||
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const where = readFileSync(marker, 'utf8').trim()
|
||||
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
|
||||
expect(where.endsWith(childDir.split('/').pop()!)).toBe(true)
|
||||
await childHandle.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'config') describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => {
|
||||
it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
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'))
|
||||
// Not surfaced: the systemMessage text never reaches the model request.
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
|
||||
})
|
||||
})
|
||||
|
||||
if (group === 'edge-paths') describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => {
|
||||
it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
|
||||
// Session-start injection is detached, so an immediate prompt need not observe it. Assert only
|
||||
// the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race.
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// Send immediately — do NOT wait for the session-start inject.
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('config')
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('context')
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('edge-paths')
|
||||
3
packages/hooks/hooks-claude/tests/coverage-stop.spec.ts
Normal file
3
packages/hooks/hooks-claude/tests/coverage-stop.spec.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('stop')
|
||||
@@ -1,691 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent
|
||||
* fallbacks, contextFrom-empty, and the detached-listener catch handlers. */
|
||||
|
||||
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-hc-cov-')); dirs.push(d); return d }
|
||||
function sh(d: string, name: string, body: string): string {
|
||||
const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
|
||||
}
|
||||
function hooks(d: string, h: unknown): string {
|
||||
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
|
||||
}
|
||||
|
||||
type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number }
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath, ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
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: 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> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
describe('hooks-claude coverage — config option arms + substitution + skip warning', () => {
|
||||
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
|
||||
const d = dir()
|
||||
// ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker.
|
||||
const marker = join(d, 'ran')
|
||||
sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
const path = hooks(d, {
|
||||
PreToolUse: [{ hooks: [
|
||||
{ type: 'prompt', prompt: 'skipme' }, // skipped → warn loop
|
||||
{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted
|
||||
] }],
|
||||
})
|
||||
const warn = vi.fn()
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
|
||||
ctx.logger.warn = warn as never
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(existsSync(marker)).toBe(true) // substituted command ran
|
||||
})
|
||||
|
||||
it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const warn = vi.fn()
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.logger.warn = warn as never
|
||||
let sawArgs: unknown
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// updatedInput is NOT honored — the tool ran with the ORIGINAL args.
|
||||
expect((sawArgs as { command?: string }).command).toBe('original')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => {
|
||||
it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ran')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// The prompt proceeded unchanged; no context/message injected.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
|
||||
const { CallId } = await import('@deepseek-ai/dsh-llm')
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
|
||||
expect(ran).toBe(false)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('a long stderr is truncated in the hook/result summary', async () => {
|
||||
const d = dir()
|
||||
// Emit >500 chars of stderr then exit 2.
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ 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' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
|
||||
const d = dir()
|
||||
const path = hooks(d, {})
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
const adapter = new MockAdapter([])
|
||||
await expect(harness(path, adapter, { stderrSummaryMaxChars: bad }))
|
||||
.rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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) + '…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => {
|
||||
it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`)
|
||||
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
|
||||
})
|
||||
|
||||
it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
|
||||
// A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces
|
||||
// continuation; the script self-limits to one block to avoid a loop.
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`)
|
||||
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// A second model request ran → the empty-reason block forced continuation.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
// The steering carried the fallback reason (no stderr to use).
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
|
||||
})
|
||||
|
||||
it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n')
|
||||
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
// Register a fake child agent under the id the event carries.
|
||||
const injected: string[] = []
|
||||
const childId = SessionId('child-x')
|
||||
const child = { id: childId, inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: new Session(childId) } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') })
|
||||
await waitFor(() => injected.includes('child guidance'))
|
||||
expect(injected).toContain('child guidance')
|
||||
})
|
||||
|
||||
it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => {
|
||||
const d = dir()
|
||||
// A hook command that does not exist makes runHook resolve a non-blocking
|
||||
// error (not a throw), so to hit the .catch we make the .then throw: register
|
||||
// a child whose inject throws for SubagentStart.
|
||||
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n')
|
||||
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const childId = SessionId('child-y')
|
||||
const child = { id: childId, inject: () => { throw new Error('inject boom') }, session: new Session(childId) } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') })
|
||||
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — default reasons + sparse payloads', () => {
|
||||
it('PreToolUse deny with EMPTY stderr uses the default reason', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr
|
||||
const path = hooks(d, { PreToolUse: [{ 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: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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.includes('blocked by PreToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\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' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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.includes('blocked by PostToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => {
|
||||
const d = dir()
|
||||
// The agents registry has no entry for the id, so the child lookup yields
|
||||
// undefined and the payload falls back to base(undefined) — assert the
|
||||
// observe-only SubagentStop run still executes the hook without crashing.
|
||||
const marker = join(d, 'stopran')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' })
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — more default/sparse arms', () => {
|
||||
it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('no')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook')
|
||||
})
|
||||
|
||||
it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// ask (no reason) → degrades to deny with the registry's generic message.
|
||||
expect(ran).toBe(false)
|
||||
expect(events(agent).some(e => e.type === 'tool/result' && e.data.isError)).toBe(true)
|
||||
})
|
||||
|
||||
it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { PreToolUse: [{ 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' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) with only configPath runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so
|
||||
// the bridge must run on the raw minimal config (the per-hook timeout is
|
||||
// the protocol lib's reference default, not a config knob).
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
|
||||
it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => {
|
||||
const d = dir()
|
||||
// `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not
|
||||
// 2 → no decision), so the tool proceeds; the hook/result records exit 127.
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127)
|
||||
})
|
||||
|
||||
it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\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' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => {
|
||||
it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
||||
// The seams cannot yet honor `continue:false` as a hard halt. The log must still record the
|
||||
// stop decision while execution and the turn continue normally.
|
||||
const d = dir()
|
||||
const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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
|
||||
expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion
|
||||
})
|
||||
|
||||
it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\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' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
|
||||
// additionalContext also injected (the block + context arm).
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => {
|
||||
// The block's hookEventName (UserPromptSubmit) mismatches the firing event
|
||||
// (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs.
|
||||
const d = dir()
|
||||
const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
|
||||
})
|
||||
|
||||
it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => {
|
||||
// The default ACP wiring sets no projectDir. A stock CC hook that references
|
||||
// $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace,
|
||||
// not an empty string. The hook echoes the var as additionalContext.
|
||||
const d = dir()
|
||||
const workspace = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ran')])
|
||||
const ctx = await harness(path, adapter) // NB: no projectDir
|
||||
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(events(handle.agent).some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// A context-only hook delegates with `next()` and folds its context, so a downstream policy
|
||||
// listener can still veto the prompt.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(path, adapter)
|
||||
// A later listener that blocks every prompt (registered AFTER the bridge).
|
||||
const { SessionId: AId } = await import('@deepseek-ai/dsh-session')
|
||||
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
|
||||
const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// the downstream block won: the model was never called, no user/message was
|
||||
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
|
||||
})
|
||||
|
||||
it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => {
|
||||
// Both the bridge hook and a later prompt-submit listener attach context; the
|
||||
// request must see BOTH (concatContext keeps the downstream one too).
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
ctx.on('agent/prompt-submit', async () => ({
|
||||
kind: 'allow' as const,
|
||||
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
|
||||
additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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')
|
||||
expect(req).toContain('from-downstream')
|
||||
expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved
|
||||
// the original prompt was replaced by the downstream rewrite
|
||||
const userMsg = events(agent).find(e => e.type === 'user/message')
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
|
||||
})
|
||||
|
||||
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
|
||||
// The bridge hook adds context; a later post-execute listener accepts with a
|
||||
// content rewrite. Both the rewrite and the bridge context survive.
|
||||
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, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
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('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
|
||||
// block arm).
|
||||
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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
|
||||
// the bridge's context still landed (folded onto the block)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — executor reject + no-open-turn', () => {
|
||||
it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = await harness(path, adapter)
|
||||
// Force the executor to reject (an infrastructure fault) so runHook's catch
|
||||
// yields a HookOutput with exitCode undefined → the `exitCode` spread false arm.
|
||||
const bash = ctx.bash
|
||||
bash.run = (() => Promise.reject(new Error('executor down')))
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — detached-listener catch handlers', () => {
|
||||
it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
// Make inject throw, forcing the SessionStart .catch path.
|
||||
const original = agent.inject.bind(agent)
|
||||
let threw = false
|
||||
agent.inject = (() => { threw = true; throw new Error('inject boom') })
|
||||
await waitFor(() => threw)
|
||||
expect(threw).toBe(true)
|
||||
agent.inject = original
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => {
|
||||
it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
|
||||
// The server launch directory and session cwd deliberately differ. The marker proves the
|
||||
// bridge passes `session/new.cwd` instead of falling back to the executor default.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
// The hook is invoked with cwd = session dir, so a relative marker path lands there.
|
||||
hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the session cwd).
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const where = readFileSync(marker, 'utf8').trim()
|
||||
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
|
||||
expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
|
||||
// `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint`
|
||||
// receives that agent and runs in the child's cwd rather than the executor default.
|
||||
const serverDir = dir()
|
||||
const childDir = dir()
|
||||
const marker = join(childDir, 'stopwhere')
|
||||
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] })
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
|
||||
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const where = readFileSync(marker, 'utf8').trim()
|
||||
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
|
||||
expect(where.endsWith(childDir.split('/').pop()!)).toBe(true)
|
||||
await childHandle.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => {
|
||||
it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => {
|
||||
const d = dir()
|
||||
const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
||||
// Not surfaced: the systemMessage text never reaches the model request.
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => {
|
||||
it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
|
||||
// Session-start injection is detached, so an immediate prompt need not observe it. Assert only
|
||||
// the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race.
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
// Send immediately — do NOT wait for the session-start inject.
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
|
||||
@@ -41,13 +41,15 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
|
||||
| Codex hook | Harness seam | Mapping |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` |
|
||||
| `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.
|
||||
|
||||
Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `null`, preserving the Codex `string | null` shape. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn.
|
||||
|
||||
`SessionStart` — the one emit point — runs detached; each run chain is tracked, and disposing the bridge aborts a still-running hook process, then drains the continuation before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`).
|
||||
|
||||
## Context source
|
||||
|
||||
@@ -29,18 +29,21 @@
|
||||
"@deepseek-ai/dsh-hook-protocol": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
appendHookInvoked,
|
||||
@@ -163,17 +164,16 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return { content, source: PLUGIN_SOURCE }
|
||||
}
|
||||
|
||||
/** Merge hook context while retaining this bridge's plugin-level source. */
|
||||
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
|
||||
if (!theirs) return ours
|
||||
return { content: [...ours.content, ...theirs.content], source: ours.source }
|
||||
/** Prepend one context without flattening downstream provenance or metadata. */
|
||||
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] {
|
||||
return [ours, ...theirs ?? []]
|
||||
}
|
||||
|
||||
// SessionStart injects plain stdout when its detached hook resolves; a slow
|
||||
// hook may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
|
||||
detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
const context = contextFrom(merged)
|
||||
if (context) agent.inject(context.content, { source: context.source })
|
||||
@@ -185,7 +185,7 @@ 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> => {
|
||||
const turn = lastTurn(agent)
|
||||
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(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 })
|
||||
/* 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
|
||||
@@ -196,14 +196,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return {
|
||||
kind: 'allow',
|
||||
...downstream.content !== undefined ? { content: downstream.content } : {},
|
||||
additionalContext: concatContext(ours, downstream.additionalContext),
|
||||
additionalContexts: prependContext(ours, downstream.additionalContexts),
|
||||
}
|
||||
})
|
||||
|
||||
// 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(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, ...exec.signal ? { signal: exec.signal } : {} })
|
||||
/* jscpd:ignore-end */
|
||||
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
|
||||
return next()
|
||||
@@ -213,22 +213,22 @@ 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(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, ...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),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -237,7 +237,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// 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(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
|
||||
const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
|
||||
/* jscpd:ignore-end */
|
||||
if (merged.decision === 'deny') {
|
||||
// A blocking Stop hook forces continuation; a block with no reason (exit 2,
|
||||
@@ -271,10 +271,12 @@ function blocksToText(content: ContentBlock[]): string {
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Base fields on every Codex payload (no turn_id). */
|
||||
function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
|
||||
function base(ctx: Context, agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
|
||||
return {
|
||||
session_id: agent?.session.header.id ?? '',
|
||||
transcript_path: null,
|
||||
transcript_path: agent === undefined
|
||||
? null
|
||||
: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? null,
|
||||
cwd: agent?.session.header.cwd ?? process.cwd(),
|
||||
hook_event_name: event,
|
||||
model,
|
||||
@@ -283,8 +285,8 @@ function base(agent: Agent | undefined, event: string, model: string): Record<st
|
||||
}
|
||||
|
||||
/** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */
|
||||
function turnBase(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
|
||||
return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) }
|
||||
function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
|
||||
return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(agent)) }
|
||||
}
|
||||
|
||||
/** Extract a `command` string from a tool call's parsed arguments, else ''. */
|
||||
@@ -296,14 +298,14 @@ function commandOf(args: unknown): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function preToolPayload(exec: ToolExecution, model: string): Record<string, unknown> {
|
||||
function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record<string, unknown> {
|
||||
// `tool_name` is the REAL tool name (matching the `exec.name` matcher subject);
|
||||
// a hardcoded constant would disagree with what the matcher tests and make a
|
||||
// config's tool matcher never fire. `tool_input` keeps Codex's `{ command }`
|
||||
// shape (its shell payload), derived from the call's `command` arg when present.
|
||||
return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
|
||||
return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
|
||||
}
|
||||
|
||||
function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record<string, unknown> {
|
||||
return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
|
||||
function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record<string, unknown> {
|
||||
return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
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'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
@@ -41,11 +39,7 @@ function writeHooks(dir: string, hooks: unknown): void {
|
||||
|
||||
async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
|
||||
@@ -82,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(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'run ls' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -103,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(SessionId('a1'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -118,7 +112,7 @@ describe('hooks-codex bridge', () => {
|
||||
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
@@ -128,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(SessionId('a1'), { 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,17 +136,13 @@ describe('hooks-codex bridge', () => {
|
||||
writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
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(SessionId('a1'), { 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
|
||||
@@ -168,18 +158,14 @@ describe('hooks-codex bridge', () => {
|
||||
const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
|
||||
writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
ctx.agentLoop.create(SessionId('a1'), { 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()
|
||||
|
||||
637
packages/hooks/hooks-codex/tests/coverage-cases.ts
Normal file
637
packages/hooks/hooks-codex/tests/coverage-cases.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
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 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'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
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 }
|
||||
function sh(d: string, name: string, body: string): string {
|
||||
const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
|
||||
}
|
||||
function hooks(d: string, h: unknown): string {
|
||||
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
|
||||
}
|
||||
|
||||
type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string }
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
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: 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> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
export type CoverageGroup = 'prompt' | 'post-tool' | 'result-shape' | 'edge-paths' | 'payload'
|
||||
|
||||
/** Register independently schedulable slices of the hooks-codex coverage matrix. */
|
||||
export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGroup[]): void {
|
||||
const selected = new Set(typeof groups === 'string' ? [groups] : groups)
|
||||
if (selected.has('prompt')) describe('hooks-codex coverage — prompt decision mapping', () => {
|
||||
it('uses the persistence locator for transcript_path and null without one', async () => {
|
||||
async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string | null }; expected: string | undefined }> {
|
||||
const d = dir()
|
||||
const cap = join(d, 'payload')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
|
||||
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(SessionId('transcript'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
return {
|
||||
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null },
|
||||
expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path,
|
||||
}
|
||||
}
|
||||
|
||||
const located = await capture(dir())
|
||||
expect(located.payload.transcript_path).toBe(located.expected)
|
||||
expect((await capture()).payload.transcript_path).toBeNull()
|
||||
}, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
|
||||
|
||||
it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
|
||||
const d = dir()
|
||||
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(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')
|
||||
expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected')
|
||||
})
|
||||
|
||||
it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => {
|
||||
const d = dir()
|
||||
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(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')
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a
|
||||
// downstream policy listener can still block.
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
|
||||
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(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)
|
||||
const te = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
|
||||
})
|
||||
|
||||
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.on('agent/prompt-submit', async () => ({
|
||||
kind: 'allow' as const,
|
||||
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
|
||||
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(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')
|
||||
expect(req).toContain('from-downstream')
|
||||
expect(req).toContain('rewritten-prompt')
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
if (selected.has('post-tool')) describe('hooks-codex coverage — post-tool and session context mapping', () => {
|
||||
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', 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, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
|
||||
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)
|
||||
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(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')
|
||||
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') }] }] })
|
||||
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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
|
||||
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)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
|
||||
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('SessionStart additionalContext is injected for the first request', async () => {
|
||||
const d = dir()
|
||||
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(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)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
|
||||
})
|
||||
|
||||
it('PostToolUse block (exit 2) → isError feedback; default reason', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
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(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)
|
||||
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('PostToolUse additionalContext (clean exit) is attached after the result', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] })
|
||||
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(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)
|
||||
})
|
||||
})
|
||||
|
||||
if (selected.has('result-shape')) describe('hooks-codex coverage — hook result shape and configuration', () => {
|
||||
it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg
|
||||
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(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 ''
|
||||
})
|
||||
|
||||
it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
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(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)
|
||||
expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
|
||||
})
|
||||
|
||||
it('a long stderr is truncated in the hook/result summary', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
|
||||
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(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)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
|
||||
const d = dir()
|
||||
hooks(d, {})
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
const adapter = new MockAdapter([])
|
||||
await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad }))
|
||||
.rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
|
||||
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(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) + '…')
|
||||
})
|
||||
|
||||
it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [
|
||||
{ type: 'command', command: 'bg.sh', async: true }, // skipped → warn
|
||||
{ type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) },
|
||||
] }] })
|
||||
const warn = vi.fn()
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
ctx.logger.warn = warn as never
|
||||
// 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(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'))
|
||||
})
|
||||
|
||||
it('a no-op clean hook proceeds (contextFrom empty → next)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
})
|
||||
|
||||
it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => {
|
||||
const d = dir()
|
||||
// The hook touches a marker so we can wait for it to ACTUALLY FINISH before
|
||||
// asserting absence — a completed turn alone would not prove the detached
|
||||
// session-start hook ran, making the absence check a false pass.
|
||||
const marker = join(d, 'ss-ran')
|
||||
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(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)
|
||||
})
|
||||
|
||||
it('a throwing SessionStart inject is contained (logged)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] })
|
||||
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(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'))
|
||||
})
|
||||
})
|
||||
|
||||
if (selected.has('edge-paths')) describe('hooks-codex coverage — matching and no-agent edge paths', () => {
|
||||
it('a clean PreToolUse with no decision allows the tool (no deny)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
})
|
||||
|
||||
it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => {
|
||||
const d = dir()
|
||||
// /^Edit$/ does not match the tool name "Bash" → the group is skipped.
|
||||
hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(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)
|
||||
})
|
||||
|
||||
it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
||||
// Honoring `continue:false` is deferred — the seams have no hard-halt
|
||||
// primitive. Assert the LOG records the halt request AND that the run is not
|
||||
// actually halted (the tool still runs, the turn completes).
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(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
|
||||
expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
|
||||
})
|
||||
|
||||
it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
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(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)
|
||||
})
|
||||
|
||||
it('PostToolUse block AND additionalContext are surfaced together', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] })
|
||||
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(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)
|
||||
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
|
||||
})
|
||||
|
||||
it('commandOf reads a non-string command arg as an empty command', async () => {
|
||||
const d = dir()
|
||||
// The tool-call arguments carry `command` as a NUMBER → commandOf's
|
||||
// `typeof command === 'string'` false arm → '' (the payload's tool_input.command).
|
||||
const cap = join(d, 'payload')
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
|
||||
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(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('')
|
||||
})
|
||||
|
||||
it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ 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' } })
|
||||
expect(ran).toBe(false) // denied
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('a no-agent direct PostToolUse run attaches context with no session to record', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] })
|
||||
const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
|
||||
ctx.tools.register(defineTool({ 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' } })
|
||||
expect(result.isError).toBeFalsy()
|
||||
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 () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(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)
|
||||
})
|
||||
})
|
||||
|
||||
if (selected.has('payload')) describe('hooks-codex coverage — continuation, payload, and cwd mapping', () => {
|
||||
it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => {
|
||||
// Regression: an exit-2 Stop hook with no stderr yields decision 'deny' +
|
||||
// reason undefined; the turn must STILL force-continue, not silently stop.
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
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(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')
|
||||
})
|
||||
|
||||
it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => {
|
||||
// Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout
|
||||
// as additionalContext (unlike CC, which needs a JSON hookSpecificOutput).
|
||||
const d = dir()
|
||||
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(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')
|
||||
})
|
||||
|
||||
it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => {
|
||||
// SessionStart cannot block, but non-clean stdout still must not become context. The marker
|
||||
// waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches
|
||||
// the codec's structured-stdout rule.
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
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(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)
|
||||
})
|
||||
|
||||
it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => {
|
||||
// Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked
|
||||
// and the handler falls through to the context path — the gate must still
|
||||
// suppress the error hook's stdout ("stale" never reaches the model).
|
||||
const d = dir()
|
||||
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(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')
|
||||
})
|
||||
|
||||
it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => {
|
||||
const d = dir()
|
||||
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(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)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
})
|
||||
|
||||
it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => {
|
||||
// A structured (JSON) stdout must go through the hookSpecificOutput path, not
|
||||
// be dumped verbatim as context — the `!startsWith('{')` gate guards this.
|
||||
const d = dir()
|
||||
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(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')
|
||||
})
|
||||
|
||||
it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => {
|
||||
// Regression: the payload once hardcoded tool_name "Bash", disagreeing with
|
||||
// the exec.name matcher subject — a config matcher on the real name would
|
||||
// then never fire. Capture the payload and assert tool_name === the real name.
|
||||
const d = dir()
|
||||
const cap = join(d, 'payload')
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
|
||||
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(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')
|
||||
expect(payload.tool_input.command).toBe('ls')
|
||||
})
|
||||
|
||||
it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => {
|
||||
// A regex matcher matching the real tool name must select the hook — proving
|
||||
// the matcher subject and the payload tool_name agree.
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
|
||||
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(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)
|
||||
})
|
||||
|
||||
it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] })
|
||||
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(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')
|
||||
})
|
||||
|
||||
it('runs an agent-scoped hook in the session cwd, not the executor default', async () => {
|
||||
// Same regression as the CC bridge: the Codex bridge must thread the session
|
||||
// cwd as the hook workdir. Executor default = serverDir; session cwd =
|
||||
// sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
|
||||
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({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases(['post-tool', 'payload'])
|
||||
3
packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts
Normal file
3
packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases(['prompt', 'edge-paths'])
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineCoverageCases } from './coverage-cases.ts'
|
||||
|
||||
defineCoverageCases('result-shape')
|
||||
@@ -1,561 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
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 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 }
|
||||
function sh(d: string, name: string, body: string): string {
|
||||
const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
|
||||
}
|
||||
function hooks(d: string, h: unknown): string {
|
||||
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
|
||||
}
|
||||
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
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: 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> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
|
||||
const d = dir()
|
||||
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(SessionId('a1'), { 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')
|
||||
expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected')
|
||||
})
|
||||
|
||||
it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => {
|
||||
const d = dir()
|
||||
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(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a
|
||||
// downstream policy listener can still block.
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
|
||||
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(SessionId('a1'), { 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)
|
||||
const te = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
|
||||
})
|
||||
|
||||
it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
ctx.on('agent/prompt-submit', async () => ({
|
||||
kind: 'allow' as const,
|
||||
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
|
||||
additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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')
|
||||
expect(req).toContain('from-downstream')
|
||||
expect(req).toContain('rewritten-prompt')
|
||||
})
|
||||
|
||||
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', 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, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
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('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') }] }] })
|
||||
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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { 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)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
|
||||
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('SessionStart additionalContext is injected for the first request', async () => {
|
||||
const d = dir()
|
||||
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(SessionId('a1'), { 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)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
|
||||
})
|
||||
|
||||
it('PostToolUse block (exit 2) → isError feedback; default reason', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
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(SessionId('a1'), { 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)
|
||||
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('PostToolUse additionalContext (clean exit) is attached after the result', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] })
|
||||
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(SessionId('a1'), { 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)
|
||||
})
|
||||
|
||||
it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg
|
||||
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(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
|
||||
})
|
||||
|
||||
it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
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(SessionId('a1'), { 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)
|
||||
expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
|
||||
})
|
||||
|
||||
it('a long stderr is truncated in the hook/result summary', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
|
||||
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(SessionId('a1'), { 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)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
|
||||
const d = dir()
|
||||
hooks(d, {})
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
const adapter = new MockAdapter([])
|
||||
await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad }))
|
||||
.rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
|
||||
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(SessionId('a1'), { 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) + '…')
|
||||
})
|
||||
|
||||
it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [
|
||||
{ type: 'command', command: 'bg.sh', async: true }, // skipped → warn
|
||||
{ type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) },
|
||||
] }] })
|
||||
const warn = vi.fn()
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
ctx.logger.warn = warn as never
|
||||
// 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(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
|
||||
})
|
||||
|
||||
it('a no-op clean hook proceeds (contextFrom empty → next)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
})
|
||||
|
||||
it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => {
|
||||
const d = dir()
|
||||
// The hook touches a marker so we can wait for it to ACTUALLY FINISH before
|
||||
// asserting absence — a completed turn alone would not prove the detached
|
||||
// session-start hook ran, making the absence check a false pass.
|
||||
const marker = join(d, 'ss-ran')
|
||||
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(SessionId('a1'), { 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)
|
||||
})
|
||||
|
||||
it('a throwing SessionStart inject is contained (logged)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] })
|
||||
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(SessionId('a1'), { 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'))
|
||||
})
|
||||
|
||||
it('a clean PreToolUse with no decision allows the tool (no deny)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(ran).toBe(true)
|
||||
})
|
||||
|
||||
it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => {
|
||||
const d = dir()
|
||||
// /^Edit$/ does not match the tool name "Bash" → the group is skipped.
|
||||
hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(SessionId('a1'), { 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)
|
||||
})
|
||||
|
||||
it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
||||
// Honoring `continue:false` is deferred — the seams have no hard-halt
|
||||
// primitive. Assert the LOG records the halt request AND that the run is not
|
||||
// actually halted (the tool still runs, the turn completes).
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(SessionId('a1'), { 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
|
||||
expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
|
||||
})
|
||||
|
||||
it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
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(SessionId('a1'), { 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)
|
||||
})
|
||||
|
||||
it('PostToolUse block AND additionalContext are surfaced together', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] })
|
||||
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(SessionId('a1'), { 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)
|
||||
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
|
||||
})
|
||||
|
||||
it('commandOf reads a non-string command arg as an empty command', async () => {
|
||||
const d = dir()
|
||||
// The tool-call arguments carry `command` as a NUMBER → commandOf's
|
||||
// `typeof command === 'string'` false arm → '' (the payload's tool_input.command).
|
||||
const cap = join(d, 'payload')
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
|
||||
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(SessionId('a1'), { 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('')
|
||||
})
|
||||
|
||||
it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({ 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' } })
|
||||
expect(ran).toBe(false) // denied
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('a no-agent direct PostToolUse run attaches context with no session to record', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] })
|
||||
const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
|
||||
ctx.tools.register(defineTool({ 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' } })
|
||||
expect(result.isError).toBeFalsy()
|
||||
expect(result.additionalContext?.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 () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
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(SessionId('a1'), { 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)
|
||||
})
|
||||
|
||||
it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => {
|
||||
// Regression: an exit-2 Stop hook with no stderr yields decision 'deny' +
|
||||
// reason undefined; the turn must STILL force-continue, not silently stop.
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
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(SessionId('a1'), { 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')
|
||||
})
|
||||
|
||||
it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => {
|
||||
// Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout
|
||||
// as additionalContext (unlike CC, which needs a JSON hookSpecificOutput).
|
||||
const d = dir()
|
||||
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(SessionId('a1'), { 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')
|
||||
})
|
||||
|
||||
it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => {
|
||||
// SessionStart cannot block, but non-clean stdout still must not become context. The marker
|
||||
// waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches
|
||||
// the codec's structured-stdout rule.
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
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(SessionId('a1'), { 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)
|
||||
})
|
||||
|
||||
it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => {
|
||||
// Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked
|
||||
// and the handler falls through to the context path — the gate must still
|
||||
// suppress the error hook's stdout ("stale" never reaches the model).
|
||||
const d = dir()
|
||||
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(SessionId('a1'), { 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')
|
||||
})
|
||||
|
||||
it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => {
|
||||
const d = dir()
|
||||
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(SessionId('a1'), { 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)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
})
|
||||
|
||||
it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => {
|
||||
// A structured (JSON) stdout must go through the hookSpecificOutput path, not
|
||||
// be dumped verbatim as context — the `!startsWith('{')` gate guards this.
|
||||
const d = dir()
|
||||
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(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
|
||||
})
|
||||
|
||||
it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => {
|
||||
// Regression: the payload once hardcoded tool_name "Bash", disagreeing with
|
||||
// the exec.name matcher subject — a config matcher on the real name would
|
||||
// then never fire. Capture the payload and assert tool_name === the real name.
|
||||
const d = dir()
|
||||
const cap = join(d, 'payload')
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] })
|
||||
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(SessionId('a1'), { 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')
|
||||
expect(payload.tool_input.command).toBe('ls')
|
||||
})
|
||||
|
||||
it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => {
|
||||
// A regex matcher matching the real tool name must select the hook — proving
|
||||
// the matcher subject and the payload tool_name agree.
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
|
||||
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(SessionId('a1'), { 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)
|
||||
})
|
||||
|
||||
it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] })
|
||||
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(SessionId('a1'), { 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')
|
||||
})
|
||||
|
||||
it('runs an agent-scoped hook in the session cwd, not the executor default', async () => {
|
||||
// Same regression as the CC bridge: the Codex bridge must thread the session
|
||||
// cwd as the hook workdir. Executor default = serverDir; session cwd =
|
||||
// sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
|
||||
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({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user