diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 73cd66df10..0cc4458f52 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -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 { + 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) }) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index fb8b11e739..ca3a143deb 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -45,6 +45,15 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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 { + 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[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[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' }]) diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index d10f2d7df1..c32d0b3b91 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -36,6 +36,15 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { 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 { + 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') })