import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent, type TurnEndReason, type UserMessage, } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type PreStepDecision, type SessionStartSource, } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** * The interception seams introduced by the hooks taxonomy: `agent/pre-step`, * `agent/session-start`, `agent/turn-stopping`, and the * `tools/pre-execute` / `tools/post-execute` * split with `additionalContexts` buffering. These verify the canonical event * surface a hook bridge (or a native plugin) programs against, WITHOUT any * external protocol — a native plugin uses the typed decisions directly. */ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() } }) }) } function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } describe('agent/pre-step', () => { it('enter (default via next) records the user/message unchanged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] ctx.on('agent/pre-step', async ({ messages }, next) => { seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join('')) return next() }) send(agent, 'hello') await waitForIdle(ctx, agent) expect(seen).toEqual(['hello']) const userMsg = events(agent).find(e => e.type === 'user/message') expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }]) }) it('reports the request coordinates for initial and tool-continuation prompts', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done'), ]) const ctx = await harness(adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string', required: true } }, execute: async ({ text }) => [{ type: 'text', text }], })) const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) const seen: Array<{ turn: number; step: number; messages: number }> = [] ctx.on('agent/pre-step', async ({ messages, turn, step }, next) => { seen.push({ turn, step, messages: messages.length }) return next() }) send(agent, 'hello') await waitForIdle(ctx, agent) expect(seen).toEqual([ { turn: 1, step: 1, messages: 1 }, { turn: 1, step: 2, messages: 0 }, ]) }) it('publishes frozen input without replacing its identity', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() const observed: UserMessage[] = [] ctx.on('agent/pre-step', async ({ agent: subject, messages }) => { if (subject !== agent) return { kind: 'enter', messages } const message = messages[0]! expect(Object.isFrozen(message)).toBe(true) expect(Object.isFrozen(message.content)).toBe(true) expect(Object.isFrozen(message.content[0])).toBe(true) expect(Object.isFrozen(message.source)).toBe(true) expect(() => { const block = message.content[0] if (block?.type === 'text') block.text = 'listener mutation' }).toThrow() observed.push(message) entered.resolve(undefined) return decision.promise }) const input: UserMessage = createUserMessage({ content: [{ type: 'text', text: 'accepted text' }], source: { kind: 'plugin', plugin: 'accepted source' }, }) const idle = waitForIdle(ctx, agent) agent.followup(input) await entered.promise const block = input.content[0] expect(() => { if (block?.type === 'text') block.text = 'caller mutation' }).toThrow(TypeError) expect(() => { if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation' }).toThrow(TypeError) decision.resolve({ kind: 'enter', messages: [input] }) await idle expect(observed).toHaveLength(1) expect(observed[0]).not.toBe(input) expect(observed[0]).toMatchObject({ content: [{ type: 'text', text: 'accepted text' }], source: { kind: 'plugin', plugin: 'accepted source' }, }) const userMsg = events(agent).find(event => event.type === 'user/message') expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input) }) it('enter with content rewrites the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async ({ messages }): Promise => ({ kind: 'enter', messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }], })) send(agent, 'original') await waitForIdle(ctx, agent) const userMsg = events(agent).find(e => e.type === 'user/message') expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'REWRITTEN' }]) // the rewritten prompt is what reached the model expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('REWRITTEN') expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original') }) it('enter with additional messages records separately sourced context in the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async ({ messages }): Promise => ({ kind: 'enter', messages: [...messages, createUserMessage({ content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' }, })], })) send(agent, 'go') await waitForIdle(ctx, agent) const log = events(agent) const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user') const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(userMsg).toBeDefined() expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') }) it('does not open another step when a completed turn rewrites pending input to empty', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), { provider: 'mock', model: 'mock', }) ctx.on('agent/turn-stopping', ({ agent: subject }) => { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'pending context' }], source: { kind: 'plugin', plugin: 'test' }, })) }) ctx.on('agent/pre-step', async ({ step }, next) => { const decision = await next() return step === 1 || decision.kind === 'reject' ? decision : { kind: 'enter', messages: [] } }) send(agent, 'finish once') await agent.whenIdle() expect(adapter.requests).toHaveLength(1) expect(events(agent).filter(event => event.type === 'step/start')).toHaveLength(1) }) it('reject closes the claimed prompt turn without a step or model call', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async (): Promise => ({ kind: 'reject' })) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } })) await agent.whenIdle() // the model was never called expect(adapter.requests).toHaveLength(0) const log = events(agent) expect(log.filter(e => e.type === 'turn/start' || e.type === 'turn/end').map(e => e.type)) .toEqual(['turn/start', 'turn/end']) expect(log.some(e => e.type === 'user/message')).toBe(false) expect(log.some(e => e.type === 'step/start')).toBe(false) expect(reasons).toEqual([{ kind: 'blocked' }]) }) it('stages inject and steer during pre-step for the entered turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() let claimed: UserMessage[] = [] let firstProposal = true ctx.on('agent/pre-step', async ({ messages }) => { if (!firstProposal) return { kind: 'enter', messages } firstProposal = false claimed = messages entered.resolve(undefined) return decision.promise }) const idle = waitForIdle(ctx, agent) send(agent, 'entered prompt') await entered.promise expect(agent.status).toBe('running') expect(events(agent).some(event => event.type === 'turn/start')).toBe(true) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'attached context' }], source: { kind: 'plugin', plugin: 'test' }, })) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'pre-step steering' }], source: { kind: 'user' } })) expect(events(agent).some(event => event.type === 'user/message')).toBe(false) expect(agent.inbox.nextStep.map(message => message.content[0])) .toEqual([ { type: 'text', text: 'attached context' }, { type: 'text', text: 'pre-step steering' }, ]) decision.resolve({ kind: 'enter', messages: claimed }) await idle expect(agent.inbox.hasPending).toBe(false) const staged = events(agent).filter(event => event.type === 'turn/start' || event.type === 'user/message') expect(staged.map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'user/message', 'user/message', ]) expect(staged[1]?.type === 'user/message' && staged[1].data.content) .toEqual([{ type: 'text', text: 'entered prompt' }]) expect(staged[2]?.type === 'user/message' && staged[2].data.content) .toEqual([{ type: 'text', text: 'attached context' }]) expect(staged[3]?.type === 'user/message' && staged[3].data.content) .toEqual([{ type: 'text', text: 'pre-step steering' }]) const firstRequest = JSON.stringify(adapter.requests[0]?.messages) expect(firstRequest).toContain('entered prompt') expect(firstRequest).not.toContain('attached context') expect(firstRequest).not.toContain('pre-step steering') const nextRequest = JSON.stringify(adapter.requests[1]?.messages) expect(nextRequest).toContain('attached context') expect(nextRequest).toContain('pre-step steering') }) it('preserves input staged after the blocked batch was claimed', async () => { const adapter = new MockAdapter([textResponse('retried')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() const disposeBlock = ctx.on('agent/pre-step', async () => { entered.resolve(undefined) return decision.promise }) const blockedIdle = waitForIdle(ctx, agent) send(agent, 'blocked prompt') await entered.promise agent.inject(createUserMessage({ content: [{ type: 'text', text: 'staged context' }], source: { kind: 'plugin', plugin: 'test' }, })) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } })) decision.resolve({ kind: 'reject' }) await blockedIdle expect(agent.inbox.nextStep.map(message => message.content[0])) .toEqual([ { type: 'text', text: 'staged context' }, { type: 'text', text: 'staged steering' }, ]) expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end') .map(event => event.type)).toEqual(['turn/start', 'turn/end']) expect(adapter.requests).toEqual([]) disposeBlock() send(agent, 'resume') await waitForIdle(ctx, agent) const staged = events(agent).filter(event => event.type === 'user/message') expect(staged.map(event => event.type)).toEqual([ 'user/message', 'user/message', 'user/message', ]) expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt') expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged context') expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering') }) it('preserves later queued work when a step is rejected', async () => { const adapter = new MockAdapter([ textResponse('continued'), textResponse('wake reply'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-order'), { provider: 'mock', model: 'mock', }) ctx.on('agent/pre-step', async ({ messages }, next) => { const decision = await next() return messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) ? { kind: 'reject' as const } : decision }) ctx.on('agent/pre-step', async ({ agent: subject, messages }, next) => { if (messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'earlier state change' }], source: { kind: 'plugin', plugin: 'test' }, })) subject.steer(createUserMessage({ content: [{ type: 'text', text: 'earlier steering' }], source: { kind: 'user' }, })) } return next() }) const idle = waitForIdle(ctx, agent) send(agent, 'blocked prompt') send(agent, 'later prompt') await idle expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end') .map(event => event.type)).toEqual(['turn/start', 'turn/end']) expect(agent.inbox.nextStep.map(message => message.content[0])) .toEqual([ { type: 'text', text: 'earlier state change' }, { type: 'text', text: 'earlier steering' }, ]) expect(agent.inbox.nextTurn.map(message => message.content[0])) .toEqual([{ type: 'text', text: 'later prompt' }]) expect(adapter.requests).toEqual([]) const resumed = waitForIdle(ctx, agent) send(agent, 'wake') await resumed const request = JSON.stringify(adapter.requests[0]?.messages) expect(request).toContain('earlier state change') expect(request).toContain('earlier steering') expect(request).toContain('later prompt') expect(request).not.toContain('blocked prompt') }) it('preserves context-only injection staged after pre-step began', async () => { const adapter = new MockAdapter([textResponse('continued')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const decision = Promise.withResolvers() const disposeBlock = ctx.on('agent/pre-step', async () => { entered.resolve(undefined) return decision.promise }) const idle = waitForIdle(ctx, agent) send(agent, 'blocked prompt') await entered.promise agent.inject(createUserMessage({ content: [{ type: 'text', text: 'independent context' }], source: { kind: 'plugin', plugin: 'test' }, })) decision.resolve({ kind: 'reject' }) await idle const log = events(agent) expect(log.some(event => event.type === 'user/message')).toBe(false) expect(agent.inbox.nextStep.map(message => message.content[0])) .toEqual([{ type: 'text', text: 'independent context' }]) expect(adapter.requests).toEqual([]) disposeBlock() const resumed = waitForIdle(ctx, agent) send(agent, 'wake') await resumed expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('independent context') expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt') }) it('leaves inbox state unchanged when its durable append fails', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), { provider: 'mock', model: 'mock', }) vi.spyOn(agent.session, 'append').mockImplementationOnce(() => { throw new Error('append unavailable') }) expect(() => { send(agent, 'blocked prompt') }).toThrow('append unavailable') expect(events(agent)).toEqual([]) expect(agent.inbox.hasPending).toBe(false) expect(agent.status).toBe('idle') }) it('a blocked prompt preserves adjacent queued prompts', async () => { const adapter = new MockAdapter([ textResponse('safe reply'), textResponse('wake reply'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') return text === 'secret' ? { kind: 'reject' } : next() }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'secret') send(agent, 'safe') await waitForIdle(ctx, agent) const log = events(agent) expect(log.filter(e => e.type === 'user/message')).toHaveLength(0) expect(adapter.requests).toHaveLength(0) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) expect(reasons).toEqual([{ kind: 'blocked' }]) expect(agent.inbox.nextTurn.map(message => message.content[0])) .toEqual([{ type: 'text', text: 'safe' }]) const resumed = waitForIdle(ctx, agent) send(agent, 'wake') await resumed expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('safe') expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('secret') }) it('a throwing pre-step listener reports the driver error and retains adjacent work', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('agent/pre-step', async ({ messages }) => { if (!threw) { threw = true; throw new Error('prompt hook broke') } return { kind: 'enter' as const, messages } }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] const statuses: string[] = [] ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) }) const idle = waitForIdle(ctx, agent) send(agent, 'first') send(agent, 'second') await idle expect(errors).toEqual([expect.objectContaining({ message: 'prompt hook broke' })]) const log = events(agent) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) expect(reasons).toEqual([{ kind: 'error', error: { message: 'prompt hook broke', code: 'UNKNOWN' }, }]) expect(statuses).toEqual(['running', 'idle']) expect(adapter.requests).toHaveLength(0) expect(agent.inbox.nextTurn.map(message => message.content[0])) .toEqual([{ type: 'text', text: 'second' }]) }) }) describe('agent/session-start', () => { it('fires once with source "startup" for a fresh create, before the first turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const sources: SessionStartSource[] = [] ctx.on('agent/session-start', ({ source }) => void sources.push(source)) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires synchronously at create, before any turn expect(sources).toEqual(['startup']) expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) send(agent, 'go') await waitForIdle(ctx, agent) // still only one session-start expect(sources).toEqual(['startup']) }) it('a session-start listener can inject context the first request sees', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.on('agent/session-start', ({ agent }) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })) }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) // the injected context reached the model on the first (only) request expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') // and is recorded with the plugin source, never mislabeled as a user prompt const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) }) it('a throwing session-start listener does not abort agent construction', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') }) // create must not throw — the listener error is contained/logged const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) expect(agent.id).toBe(SessionId('a1')) // and the agent still runs send(agent, 'go') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) }) }) describe('tool additionalContexts buffering across a step', () => { it('appends each call\'s contexts only AFTER all tool/results, preserving adjacency', async () => { // One assistant step with TWO tool calls; the second model response stops. const twoCalls = [ { type: 'block-start' as const, index: 0, blockType: 'tool-call' as const }, { type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: '{"text":"a"}' } }, { type: 'block-start' as const, index: 1, blockType: 'tool-call' as const }, { type: 'block-end' as const, index: 1, block: { type: 'tool-call' as const, id: CallId('c2'), name: 'echo', arguments: '{"text":"b"}' } }, { type: 'usage' as const, usage: { inputTokens: 5, outputTokens: 5 } }, { type: 'finish' as const, reason: { kind: 'tool-calls' as const } }, ] const adapter = new MockAdapter([twoCalls, textResponse('done')]) const ctx = await harness(adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Each call attaches one context naming itself. ctx.on('tools/post-execute', async (exec, _result): Promise => ({ kind: 'accept', additionalContexts: [createUserMessage({ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' }, })], })) send(agent, 'go') await waitForIdle(ctx, agent) // Event order in the log: both tool/results, THEN both injected contexts — // never interleaved (which would break tool-call/result adjacency). const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') const seqs = events(agent) const firstResult = seqs.findIndex(e => e.type === 'tool/result') const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result') const firstCtx = seqs.findIndex(e => e === injected[0]) expect(firstResult).toBeGreaterThanOrEqual(0) expect(lastResult).toBeGreaterThan(firstResult) // two results expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results // both contexts present const ctxTexts = injected .flatMap(e => (e.type === 'user/message' ? e.data.content : [])) .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) }) it('appends multiple contexts deferred by one composite tool after its outer result', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')]) const ctx = await harness(adapter) ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, async execute(_args, exec) { exec.deferContext(createUserMessage({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, })) exec.deferContext(createUserMessage({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, })) return [{ type: 'text', text: 'outer result' }] }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) const log = events(agent) const resultIndex = log.findIndex(event => event.type === 'tool/result') const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(resultIndex).toBeGreaterThanOrEqual(0) expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex) expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'a' }, { kind: 'plugin', plugin: 'b' }, ]) }) }) describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => { it('deny short-circuits dispatch into an isError result the model sees', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')]) const ctx = await harness(adapter) let ran = false ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' } return next() }) send(agent, 'go') 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.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('blocked dangerous tool'))).toBe(true) }) }) describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => { // The whole point of the interception taxonomy: a "native hook" needs no dsh-hook-protocol, // no external command, no hook/* log — it is an ordinary cordis plugin subscribing to the // canonical events and returning typed decisions. const NativeGuard = { name: 'native-guard', apply(ctx: Context) { // 1. SessionStart: seed a standing instruction. ctx.on('agent/session-start', ({ agent, source }) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })) }) // 2. PreStep: reject a forbidden prompt, annotate the rest. ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') if (text.includes('rm -rf')) { return { kind: 'reject' } } return next() }) // 3. PreToolUse: deny a dangerous tool by name. ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'danger') return { kind: 'deny', reason: 'danger tool denied' } return next() }) // 4. PostToolUse: attach context after a tool runs. ctx.on('tools/post-execute', async (_exec, _result, next): Promise => { const decision = await next() if (decision.kind === 'accept') { return { kind: 'accept', additionalContexts: [createUserMessage({ content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' }, })] } } return decision }) }, } it('all four seams fire for a real allowed turn with a tool call', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'please echo hi') await waitForIdle(ctx, agent) const log = events(agent) // session-start preamble injected expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin' && e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true) // prompt allowed → user-sourced user/message recorded expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true) // tool ran (echo allowed) and post-execute attached "audited" context expect(log.some(e => e.type === 'tool/result' && !e.data.message.content[0].isError)).toBe(true) expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin' && e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true) // NO hook/* events — a native plugin needs none expect(log.some(e => e.type.startsWith('hook/'))).toBe(false) }) it('the same plugin blocks a destructive prompt inside a no-step turn', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) const agent = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'run rm -rf /') await agent.whenIdle() expect(adapter.requests).toHaveLength(0) expect(reasons).toEqual([{ kind: 'blocked' }]) }) it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const fiber = await ctx.plugin(NativeGuard) await fiber.dispose() // After disposal, a destructive prompt is NOT blocked (the listener is gone). const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' }) send(agent, 'run rm -rf /') await waitForIdle(ctx, agent) // the prompt ran (not rejected) — proving the pre-step listener was disposed expect(adapter.requests).toHaveLength(1) expect(events(agent).some(e => e.type === 'user/message')).toBe(true) }) })