Merge remote-tracking branch 'origin/master' into worktree/agent-loop-testkit

This commit is contained in:
Tianyi Cui
2026-07-17 20:17:27 +08:00
92 changed files with 1411 additions and 178 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -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 } : {},

View File

@@ -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.

View File

@@ -29,6 +29,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../subagent/subagent"
},