test(hooks): poll for detached-hook effects instead of a fixed sleep (flake fix)

The bridge tests that drive observe-only emit listeners (session-start,
subagent/start, subagent/end) fire their hook on a detached `.then` the test
cannot await. They waited a fixed 50-80ms, which flaked under the full
test:coverage run's heavy parallel load (transform ~400s): the sleep expired
before the async hook completed, so the injected context / marker file / warn
call had not landed. Replace each fixed sleep with a `waitFor(predicate)` poll
that retries until the observable effect appears (5s deadline) — "async state is
not synchronous state": wait for the signal that actually fires, not a guessed
duration. No behavior change; the same assertions, made robust to scheduling.
This commit is contained in:
Tianyi Cui
2026-07-01 11:25:54 +08:00
parent 253eded47b
commit a72ebda723
3 changed files with 51 additions and 14 deletions
@@ -64,6 +64,20 @@ function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}
/**
* Poll `predicate` until it returns true or the deadline passes. Detached
* emit-listener hooks (session-start, subagent) fire on a `.then` the test can't
* await directly; polling for the observable EFFECT is robust under load, where a
* single fixed sleep flakes ("async state is not synchronous state").
*/
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
await new Promise(r => setTimeout(r, interval))
}
}
describe('hooks-claude bridge — UserPromptSubmit', () => {
it('a UserPromptSubmit hook that exits 2 blocks the prompt (rejected turn)', async () => {
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
@@ -239,8 +253,11 @@ describe('hooks-claude bridge — SessionStart', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// session-start fires async; wait a tick for the inject before sending.
await new Promise(r => setTimeout(r, 50))
// session-start fires async (detached .then → agent.inject); wait for the
// injected context/message to actually land before sending, rather than a
// fixed sleep that flakes under load.
await waitFor(() => events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -274,10 +291,11 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
// child lookup yields undefined and it simply runs the hook.
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1'), agentType: 'researcher' })
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), agentType: 'researcher', stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
// Both hooks run async (detached .then); let them settle.
await new Promise(r => setTimeout(r, 80))
// Both hooks run async (detached .then); poll for their marker files rather
// than a fixed sleep that flakes under load.
const { existsSync } = await import('node:fs')
await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
expect(existsSync(startMarker)).toBe(true)
expect(existsSync(stopMarker)).toBe(true)
})
@@ -45,6 +45,15 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
await new Promise(r => setTimeout(r, interval))
}
}
describe('hooks-claude coverage — config option arms + substitution + skip warning', () => {
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
@@ -177,7 +186,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
ctx.agents.register(child)
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x'), agentType: 'r' })
await new Promise(r => setTimeout(r, 80))
await waitFor(() => injected.includes('child guidance'))
expect(injected).toContain('child guidance')
})
@@ -193,7 +202,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
ctx.agents.register(child)
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') })
await new Promise(r => setTimeout(r, 80))
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
})
})
@@ -238,7 +247,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => {
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) // no agentType
await new Promise(r => setTimeout(r, 80))
await waitFor(() => existsSync(marker))
expect(existsSync(marker)).toBe(true)
})
})
@@ -307,7 +316,6 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', (
// Direct apply with only configPath — bypasses schemastery's defaults, so the
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
await new Promise(r => setTimeout(r, 10))
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
@@ -438,7 +446,7 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => {
const original = agent.inject.bind(agent)
let threw = false
agent.inject = (() => { threw = true; throw new Error('inject boom') })
await new Promise(r => setTimeout(r, 80))
await waitFor(() => threw)
expect(threw).toBe(true)
agent.inject = original
agent.send([{ type: 'text', text: 'go' }])
@@ -36,6 +36,15 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
await new Promise(r => setTimeout(r, interval))
}
}
describe('hooks-codex coverage — decision mapping paths', () => {
it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
@@ -66,7 +75,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
await new Promise(r => setTimeout(r, 60))
await waitFor(() => events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
})
@@ -148,7 +158,6 @@ describe('hooks-codex coverage — decision mapping paths', () => {
ctx.logger.warn = warn as never
// Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks.
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
await new Promise(r => setTimeout(r, 10))
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
@@ -174,7 +183,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
await new Promise(r => setTimeout(r, 60))
// A completed turn proves session-start already ran; the clean no-output hook
// injected nothing, so no context/message exists.
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
})
@@ -187,7 +197,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const warn = vi.fn(); ctx.logger.warn = warn as never
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.inject = (() => { throw new Error('inject boom') })
await new Promise(r => setTimeout(r, 60))
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
})
@@ -343,7 +353,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
await new Promise(r => setTimeout(r, 60))
await waitFor(() => events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
})