Merge remote-tracking branch 'origin/master' into worktree/agent-loop-testkit
This commit is contained in:
@@ -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"
|
||||
@@ -42,6 +43,8 @@
|
||||
"@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-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,
|
||||
@@ -197,7 +198,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// 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 })
|
||||
@@ -211,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' }
|
||||
}
|
||||
@@ -230,7 +231,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// --- 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()
|
||||
@@ -239,7 +240,7 @@ 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 ? { additionalContexts: [context] } : {} }
|
||||
@@ -261,7 +262,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// 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'
|
||||
@@ -274,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 })
|
||||
@@ -286,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 }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -316,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
|
||||
@@ -345,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 } : {},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
|
||||
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -26,10 +27,11 @@ 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 }
|
||||
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 })
|
||||
@@ -51,6 +53,28 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
|
||||
}
|
||||
|
||||
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(AgentId('transcript'), { 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.
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
|
||||
@@ -48,6 +48,8 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
|
||||
|
||||
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,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-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -41,6 +42,8 @@
|
||||
"@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-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,
|
||||
@@ -172,7 +173,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// 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 })
|
||||
@@ -184,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
|
||||
@@ -202,7 +203,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
const turn = lastTurn(exec.agent)
|
||||
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(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()
|
||||
@@ -212,7 +213,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
|
||||
const turn = lastTurn(exec.agent)
|
||||
/* jscpd:ignore-start */
|
||||
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(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 ? { additionalContexts: [context] } : {} }
|
||||
@@ -236,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,
|
||||
@@ -270,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,
|
||||
@@ -282,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 ''. */
|
||||
@@ -295,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,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -22,9 +23,11 @@ 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> {
|
||||
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 })
|
||||
@@ -46,6 +49,28 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
|
||||
}
|
||||
|
||||
describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
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(AgentId('transcript'), { 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') }] }] })
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user