Merge branch 'master' into worktree-fast-gates
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
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 LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -27,11 +28,12 @@ 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 ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -60,6 +62,28 @@ 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(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.
|
||||
@@ -479,9 +503,9 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
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 () => {
|
||||
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 (concatContext keeps the downstream one too).
|
||||
// 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 }] }] })
|
||||
@@ -490,7 +514,12 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
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' } },
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text' as const, text: 'from-downstream' }],
|
||||
source: { kind: 'plugin' as const, plugin: 'policy' },
|
||||
envelope: 'raw' as const,
|
||||
meta: { owner: 'policy' },
|
||||
}],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
@@ -502,6 +531,13 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
// 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 () => {
|
||||
@@ -522,6 +558,35 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
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(AgentId('a1'), { 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
|
||||
|
||||
Reference in New Issue
Block a user