Merge remote-tracking branch 'origin/master' into fix/hook-matcher-validation

This commit is contained in:
ZiyaZhang
2026-07-28 08:15:28 -07:00
628 changed files with 15274 additions and 6295 deletions

View File

@@ -13,8 +13,9 @@ import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { UserMessageData } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
@@ -185,14 +186,14 @@ export function apply(ctx: Context, config: Config): void {
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
/** Build additional model context from hook output, or return undefined when empty. */
function contextFrom(merged: MergedHookOutcome): UserMessageData | undefined {
function contextFrom(merged: MergedHookOutcome): UserMessage | undefined {
if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return { content, source: PLUGIN_SOURCE }
return createUserMessage({ content, source: PLUGIN_SOURCE })
}
/** Prepend one context without flattening downstream provenance or metadata. */
function prependContext(ours: UserMessageData, theirs: UserMessageData[] | undefined): UserMessageData[] {
function prependContext(ours: UserMessage, theirs: UserMessage[] | undefined): UserMessage[] {
return [ours, ...theirs ?? []]
}
@@ -203,7 +204,7 @@ export function apply(ctx: Context, config: Config): void {
detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject({ content: context.content, source: context.source })
if (context) agent.inject(context)
})
.catch((error: unknown) => {
ctx.logger.warn(`hooks-claude: SessionStart hook failed: ${String(error)}`)
@@ -212,8 +213,8 @@ export function apply(ctx: Context, config: Config): void {
// --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no
// matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/prompt-submit', async (agent, content, _source, signal, next): Promise<PromptDecision> => {
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal })
ctx.on('agent/prompt-submit', async (agent, message, signal, next): Promise<PromptDecision> => {
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, message.content), { agent, signal })
if (merged.decision === 'deny') {
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
}
@@ -267,7 +268,7 @@ export function apply(ctx: Context, config: Config): void {
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation.
const text = merged.reason ?? 'continue: blocked by Stop hook'
agent.steer({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE }))
}
})
@@ -278,7 +279,7 @@ export function apply(ctx: Context, config: Config): void {
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({ content: context.content, source: context.source })
if (context && child) child.inject(context)
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }))
})

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -100,7 +101,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'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
// The prompt was blocked before the model and before a turn opened.
@@ -121,7 +122,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'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
// The injected context reached the model and is recorded with the plugin source.
@@ -146,13 +147,13 @@ describe('hooks-claude bridge — PreToolUse', () => {
let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'use danger' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'use danger' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(ran).toBe(false)
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('danger tool blocked'))).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('danger tool blocked'))).toBe(true)
})
it('a PreToolUse hook whose matcher does NOT match leaves the tool alone', async () => {
@@ -169,12 +170,12 @@ describe('hooks-claude bridge — PreToolUse', () => {
let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'use safe' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'use safe' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(ran).toBe(true)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(false)
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(false)
})
})
@@ -191,13 +192,13 @@ describe('hooks-claude bridge — PostToolUse', () => {
const ctx = await harness(dir, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
// PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback.
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('output rejected, retry'))).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true)
})
it('a PostToolUse hook printing additionalContext attaches it after the tool result', async () => {
@@ -212,7 +213,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const ctx = await harness(dir, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const log = events(agent)
@@ -236,14 +237,14 @@ describe('hooks-claude bridge — PostToolUse', () => {
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
// `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError.
expect(ran).toBe(false)
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('needs approval'))).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('needs approval'))).toBe(true)
})
})
@@ -265,7 +266,7 @@ describe('hooks-claude bridge — SessionStart', () => {
// fixed sleep that flakes under load.
await waitFor(() => events(agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs')
@@ -359,7 +360,7 @@ describe('hooks-claude bridge — load resilience', () => {
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
// The turn ran normally — no hooks, no crash.
expect(adapter.requests).toHaveLength(1)
@@ -418,7 +419,7 @@ describe('hooks-claude bridge — load resilience', () => {
await fiber.dispose()
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -76,7 +77,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
return {
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string },
@@ -106,7 +107,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.logger.warn = warn as never
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true) // substituted command ran
})
@@ -122,7 +123,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let sawArgs: unknown
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
// updatedInput is NOT honored — the tool ran with the ORIGINAL args.
expect((sawArgs as { command?: string }).command).toBe('original')
@@ -138,7 +139,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([textResponse('ran')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
// The prompt proceeded unchanged; no injected context.
expect(adapter.requests).toHaveLength(1)
@@ -166,7 +167,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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)
@@ -191,7 +192,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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) + '…')
@@ -207,7 +208,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
@@ -223,7 +224,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
// A second model request ran → the empty-reason block forced continuation.
expect(adapter.requests).toHaveLength(2)
@@ -276,10 +277,10 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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)
expect(result?.type === 'tool/result' && result.data.message.content[0].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 () => {
@@ -290,10 +291,10 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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)
expect(result?.type === 'tool/result' && result.data.message.content[0].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 () => {
@@ -319,7 +320,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([textResponse('no')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
})
@@ -333,11 +334,11 @@ export function defineCoverageCases(group: CoverageGroup): void {
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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)
expect(events(agent).some(e => e.type === 'tool/result' && e.data.message.content[0].isError)).toBe(true)
})
it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => {
@@ -348,7 +349,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
@@ -374,7 +375,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true)
})
@@ -389,7 +390,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(ran).toBe(true)
const res = events(agent).find(e => e.type === 'hook/result')
@@ -404,10 +405,10 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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.message.content[0].isError).toBe(true)
})
})
@@ -423,7 +424,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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
@@ -440,11 +441,11 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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)
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].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 === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
})
@@ -460,7 +461,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
})
@@ -478,7 +479,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, handle.agent)
expect(events(handle.agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
@@ -496,7 +497,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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`
@@ -516,13 +517,13 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.on('agent/prompt-submit', async () => ({
kind: 'allow' as const,
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: 'from-downstream' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
}],
})],
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const req = JSON.stringify(adapter.requests[0]!.messages)
expect(req).toContain('from-bridge')
@@ -549,10 +550,10 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
@@ -565,13 +566,13 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
}],
})],
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
@@ -593,11 +594,11 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].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 === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
@@ -617,7 +618,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
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)
@@ -640,7 +641,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await waitFor(() => threw)
expect(threw).toBe(true)
agent.inject = original
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
})
@@ -668,7 +669,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, handle.agent)
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
@@ -718,7 +719,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
// Not surfaced: the systemMessage text never reaches the model request.
@@ -737,7 +738,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
})

View File

@@ -16,8 +16,9 @@ import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { UserMessageData } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
@@ -170,14 +171,14 @@ export function apply(ctx: Context, config: Config): void {
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
function contextFrom(merged: MergedHookOutcome): UserMessageData | undefined {
function contextFrom(merged: MergedHookOutcome): UserMessage | undefined {
if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return { content, source: PLUGIN_SOURCE }
return createUserMessage({ content, source: PLUGIN_SOURCE })
}
/** Prepend one context without flattening downstream provenance or metadata. */
function prependContext(ours: UserMessageData, theirs: UserMessageData[] | undefined): UserMessageData[] {
function prependContext(ours: UserMessage, theirs: UserMessage[] | undefined): UserMessage[] {
return [ours, ...theirs ?? []]
}
@@ -188,18 +189,18 @@ export function apply(ctx: Context, config: Config): void {
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({ content: context.content, source: context.source })
if (context) agent.inject(context)
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
/* jscpd:ignore-end */
})
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
ctx.on('agent/prompt-submit', async (agent, content, _source, signal, next): Promise<PromptDecision> => {
ctx.on('agent/prompt-submit', async (agent, message, signal, next): Promise<PromptDecision> => {
const payload = {
...base(ctx, agent, 'UserPromptSubmit', model),
turn_id: String(lastTurn(agent) + 1),
prompt: blocksToText(content),
prompt: blocksToText(message.content),
}
const merged = await runPoint('UserPromptSubmit', '', payload, { agent, plainStdoutAsContext: true, signal })
/* jscpd:ignore-start */
@@ -260,7 +261,7 @@ export function apply(ctx: Context, config: Config): void {
// empty stderr) still forces it — fall back to a generic steering line
// rather than letting the turn stop.
const text = merged.reason ?? 'continue: blocked by Stop hook'
agent.steer({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE }))
}
})
}

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -76,13 +77,13 @@ describe('hooks-codex bridge', () => {
let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'run ls' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run ls' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(ran).toBe(false)
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('codex blocked it'))).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true)
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true)
})
@@ -97,7 +98,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'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
@@ -114,7 +115,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([textResponse('must not run')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'cancel the hook' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'cancel the hook' }], source: { kind: 'user' } }))
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
@@ -136,7 +137,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([textResponse('fine')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
})
@@ -146,7 +147,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
})
@@ -187,7 +188,7 @@ describe('hooks-codex bridge', () => {
await fiber.dispose()
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -67,7 +68,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
return {
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null },
@@ -86,7 +87,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
})
@@ -97,7 +98,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
})
@@ -110,7 +111,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
@@ -124,13 +125,13 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.on('agent/prompt-submit', async () => ({
kind: 'allow' as const,
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: 'from-downstream' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
}],
})],
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
const req = JSON.stringify(adapter.requests[0]!.messages)
expect(req).toContain('from-bridge')
expect(req).toContain('from-downstream')
@@ -152,9 +153,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
@@ -166,13 +167,13 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
}],
})],
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
@@ -189,10 +190,10 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
@@ -204,7 +205,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => events(agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
})
@@ -215,10 +216,10 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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)
expect(r?.type === 'tool/result' && r.data.message.content[0].isError).toBe(true)
expect(r?.type === 'tool/result' && r.data.message.content[0].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 () => {
@@ -228,7 +229,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
})
})
@@ -242,7 +243,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
})
@@ -253,7 +254,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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)
@@ -266,7 +267,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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
@@ -289,7 +290,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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) + '…')
})
@@ -313,7 +314,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
})
@@ -326,7 +327,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(ran).toBe(true)
})
@@ -341,7 +342,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
})
@@ -367,7 +368,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(ran).toBe(true)
})
@@ -380,7 +381,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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)
})
@@ -396,7 +397,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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)
@@ -409,9 +410,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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)
expect(r?.type === 'tool/result' && r.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
})
it('PostToolUse block AND additionalContext are surfaced together', async () => {
@@ -421,10 +422,10 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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(r?.type === 'tool/result' && r.data.message.content[0].isError).toBe(true)
expect(r?.type === 'tool/result' && r.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
})
@@ -438,7 +439,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
expect(payload.tool_input.command).toBe('')
})
@@ -474,7 +475,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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)
})
@@ -490,7 +491,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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')
})
@@ -503,7 +504,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
})
@@ -531,7 +532,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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')
})
@@ -544,7 +545,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => events(agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
})
@@ -556,7 +557,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
})
@@ -571,7 +572,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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')
@@ -587,7 +588,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); 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)
})
@@ -599,7 +600,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }); await waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
})
@@ -623,7 +624,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.tools.register(defineContentToolFixture({ 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.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, handle.agent)
expect(existsSync(marker)).toBe(true)
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)