|
|
|
|
@@ -3,11 +3,11 @@ import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import { Context } from 'cordis'
|
|
|
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
|
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
|
|
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|
|
|
|
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
|
|
|
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
|
|
|
|
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
|
|
|
|
@@ -34,10 +34,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
|
|
|
|
function waitForIdle(ctx: Context, agent: Agent): 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] }
|
|
|
|
|
function events(agent: Agent): 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> {
|
|
|
|
|
@@ -62,7 +62,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
|
|
|
|
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('transcript'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
return {
|
|
|
|
|
@@ -81,7 +81,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('no')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(adapter.requests).toHaveLength(0)
|
|
|
|
|
const te = events(agent).findLast(e => e.type === 'turn/end')
|
|
|
|
|
@@ -93,7 +93,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
|
|
|
|
|
})
|
|
|
|
|
@@ -106,7 +106,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([textResponse('should not run')])
|
|
|
|
|
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(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(adapter.requests).toHaveLength(0)
|
|
|
|
|
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
|
|
|
|
|
@@ -125,11 +125,10 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
additionalContexts: [{
|
|
|
|
|
content: [{ type: 'text' as const, text: 'from-downstream' }],
|
|
|
|
|
source: { kind: 'plugin' as const, plugin: 'policy' },
|
|
|
|
|
envelope: 'raw' as const,
|
|
|
|
|
meta: { owner: 'policy' },
|
|
|
|
|
}],
|
|
|
|
|
}))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
const req = JSON.stringify(adapter.requests[0]!.messages)
|
|
|
|
|
expect(req).toContain('from-bridge')
|
|
|
|
|
@@ -140,7 +139,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
{ kind: 'plugin', plugin: 'hooks-codex' },
|
|
|
|
|
{ kind: 'plugin', plugin: 'policy' },
|
|
|
|
|
])
|
|
|
|
|
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
|
|
|
|
|
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
@@ -153,7 +151,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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)
|
|
|
|
|
@@ -171,11 +169,10 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
additionalContexts: [{
|
|
|
|
|
content: [{ type: 'text' as const, text: 'downstream-note' }],
|
|
|
|
|
source: { kind: 'plugin' as const, plugin: 'policy' },
|
|
|
|
|
envelope: 'raw' as const,
|
|
|
|
|
meta: { owner: 'policy' },
|
|
|
|
|
}],
|
|
|
|
|
}))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
const contexts = events(agent).filter(event => event.type === 'context/message')
|
|
|
|
|
@@ -183,7 +180,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
{ kind: 'plugin', plugin: 'hooks-codex' },
|
|
|
|
|
{ kind: 'plugin', plugin: 'policy' },
|
|
|
|
|
])
|
|
|
|
|
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
|
|
|
|
|
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
@@ -194,7 +190,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ 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(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
const result = events(agent).find(e => e.type === 'tool/result')
|
|
|
|
|
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
|
|
|
|
@@ -207,7 +203,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
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)
|
|
|
|
|
@@ -220,7 +216,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
const r = events(agent).find(e => e.type === 'tool/result')
|
|
|
|
|
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
|
|
|
|
|
@@ -233,7 +229,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
@@ -247,7 +243,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
let ran = false
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
|
|
|
|
|
})
|
|
|
|
|
@@ -258,7 +254,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
const res = events(agent).find(e => e.type === 'hook/result')
|
|
|
|
|
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
|
|
|
|
|
@@ -271,7 +267,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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)
|
|
|
|
|
@@ -294,7 +290,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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) + '…')
|
|
|
|
|
@@ -317,7 +313,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
|
|
|
|
|
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(existsSync(marker)).toBe(true)
|
|
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
|
|
|
|
|
@@ -330,7 +326,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
let ran = false
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(ran).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
@@ -344,7 +340,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
|
|
|
|
|
@@ -356,7 +352,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const warn = vi.fn(); ctx.logger.warn = warn as never
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.inject = (() => { throw new Error('inject boom') })
|
|
|
|
|
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
|
|
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
|
|
|
|
|
@@ -371,7 +367,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
let ran = false
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(ran).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
@@ -384,7 +380,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
let ran = false
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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)
|
|
|
|
|
@@ -400,7 +396,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
let ran = false
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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
|
|
|
|
|
@@ -413,7 +409,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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)
|
|
|
|
|
@@ -425,7 +421,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
const r = events(agent).find(e => e.type === 'tool/result')
|
|
|
|
|
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
|
|
|
|
|
@@ -442,7 +438,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
|
|
|
|
|
expect(payload.tool_input.command).toBe('')
|
|
|
|
|
@@ -478,7 +474,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.bash.run = (() => Promise.reject(new Error('executor down')))
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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)
|
|
|
|
|
@@ -494,7 +490,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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')
|
|
|
|
|
@@ -507,7 +503,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
|
|
|
|
|
})
|
|
|
|
|
@@ -521,7 +517,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
await waitFor(() => existsSync(marker)) // the exit-2 hook has finished
|
|
|
|
|
expect(events(agent).some(e => e.type === 'context/message'
|
|
|
|
|
&& e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false)
|
|
|
|
|
@@ -535,7 +531,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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')
|
|
|
|
|
@@ -546,7 +542,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
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)
|
|
|
|
|
@@ -560,7 +556,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] })
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
|
|
|
|
|
})
|
|
|
|
|
@@ -575,7 +571,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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')
|
|
|
|
|
@@ -591,7 +587,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
let ran = false
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); 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)
|
|
|
|
|
@@ -603,7 +599,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
const adapter = new MockAdapter([textResponse('ok')])
|
|
|
|
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
|
|
|
|
const warn = vi.fn(); ctx.logger.warn = warn as never
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
|
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
|
|
|
|
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
|
|
|
|
|
@@ -626,9 +622,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
ctx.tools.register(defineTool({ 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({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
|
|
|
|
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
|
|
|
|
handle.agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
|
|
|
|
await waitForIdle(ctx, handle.agent)
|
|
|
|
|
expect(existsSync(marker)).toBe(true)
|
|
|
|
|
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
|