fix(agent-loop): open turns before pre-step

This commit is contained in:
_Kerman
2026-08-04 13:49:35 +08:00
parent 0ac95437b4
commit d4fa26023d
154 changed files with 1104 additions and 1099 deletions

View File

@@ -129,8 +129,7 @@ export function apply(ctx: Context, config: Config): void {
* Run every command hook configured for `point` whose matcher selects
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
* Writes a `hook/invoked`/`hook/result` pair per hook when `opts.turn` names
* an open turn. Pre-turn `UserPromptSubmit` and detached lifecycle points
* omit the pair. Returns the merged outcome (a neutral,
* an open turn. Detached lifecycle points omit the pair. Returns the merged outcome (a neutral,
* already-most-restrictive view) for the caller to map onto its seam
* decision. `matchQuery` is the event's matcher subject (tool name, session
* source, …); `''` for events that ignore matchers.
@@ -217,10 +216,10 @@ export function apply(ctx: Context, config: Config): void {
// --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no
// matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => {
ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise<PreStepDecision> => {
if (messages.length === 0) return next()
const content = messages.flatMap(message => message.content)
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal })
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal })
if (merged.decision === 'deny') {
return { kind: 'reject' }
}

View File

@@ -89,7 +89,7 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
}
describe('hooks-claude bridge — UserPromptSubmit', () => {
it('a UserPromptSubmit hook that exits 2 rejects step entry without a turn', async () => {
it('a UserPromptSubmit hook that exits 2 closes a blocked turn without a step', async () => {
// UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks
// with the reason on stderr.
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
@@ -105,11 +105,11 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
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.
// The prompt was blocked inside its turn before any model step.
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
// Pre-step has no open turn in which turn-scoped hook provenance could live.
expect(events(agent).some(e => e.type === 'hook/invoked' || e.type === 'hook/result')).toBe(false)
expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
|| e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
.toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
})
it('a UserPromptSubmit hook printing additionalContext injects it for the model', async () => {
@@ -399,7 +399,9 @@ describe('hooks-claude bridge — load resilience', () => {
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'hook/invoked'
|| event.type === 'hook/result' || event.type === 'turn/end').map(event => event.type))
.toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude regex matcher'))
})

View File

@@ -322,7 +322,9 @@ export function defineCoverageCases(group: CoverageGroup): void {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
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)
expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
|| e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
.toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
})
it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
@@ -505,7 +507,9 @@ export function defineCoverageCases(group: CoverageGroup): void {
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
expect(events(agent).filter(e => e.type === 'turn/start' || e.type === 'hook/invoked'
|| e.type === 'hook/result' || e.type === 'turn/end').map(e => e.type))
.toEqual(['turn/start', 'hook/invoked', 'hook/result', 'turn/end'])
})
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {