Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

# Conflicts:
#	docs/event-producer-consumer.md
#	examples/coding-agent/tests/code-mode.e2e.ts
#	examples/coding-agent/tests/harness.ts
#	examples/coding-agent/tests/resume.e2e.ts
#	examples/cordis-agent/tests/harness.ts
#	packages/bash/tool-bash/tests/integration.spec.ts
#	packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
#	packages/cordis/tool-cordis/tests/integration.spec.ts
#	packages/core/agent-loop/tests/contract-regressions.spec.ts
#	packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
#	packages/hooks/hooks-claude/tests/bridge.spec.ts
#	packages/hooks/hooks-claude/tests/coverage.spec.ts
#	packages/hooks/hooks-codex/tests/bridge.spec.ts
#	packages/hooks/hooks-codex/tests/coverage.spec.ts
#	packages/todo/tool-todo/tests/integration.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-18 12:33:49 +08:00
657 changed files with 36714 additions and 7631 deletions

View File

@@ -13,7 +13,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
* `agent/session-start`, the reshaped `agent/turn-continuation`
* ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute`
* split with `additionalContext` buffering. These verify the canonical event
* 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.
*/
@@ -53,7 +53,7 @@ describe('agent/prompt-submit', () => {
it('allow (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'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const seen: string[] = []
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
@@ -72,7 +72,7 @@ describe('agent/prompt-submit', () => {
it('allow 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'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
@@ -87,15 +87,21 @@ describe('agent/prompt-submit', () => {
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
})
it('allow with additionalContext injects a separate context/message into the turn', async () => {
it('allow with additionalContexts injects separate context/message events into the turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const meta = { kind: 'prompt-context', version: 1 }
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } },
additionalContexts: [{
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
source: { kind: 'plugin', plugin: 'test' },
envelope: 'raw',
meta,
}],
}))
send(agent, 'go')
@@ -105,25 +111,27 @@ describe('agent/prompt-submit', () => {
const userMsg = log.find(e => e.type === 'user/message')
const ctxMsg = log.find(e => e.type === 'context/message')
expect(userMsg).toBeDefined()
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw')
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
// both the prompt and the injected context reach the model
const sent = JSON.stringify(adapter.requests[0]!.messages)
expect(sent).toContain('extra ctx')
})
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
it('a prompt-submit rewrite + additionalContexts is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
// Prompt rewrites and injected context land before `agent/pre-step`, so a
// compaction listener measures the current surface before the single derive.
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } },
additionalContexts: [{ content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
// The pre-step seam (where compaction lives) derives the surface it would act
@@ -147,7 +155,7 @@ describe('agent/prompt-submit', () => {
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'block', reason: 'blocked by policy' }))
@@ -183,7 +191,7 @@ describe('agent/prompt-submit', () => {
// the allowed prompt keeps the turn from ending rejected.
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
@@ -219,7 +227,7 @@ describe('agent/prompt-submit', () => {
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threw = false
ctx.on('agent/prompt-submit', async () => {
@@ -252,7 +260,7 @@ describe('agent/session-start', () => {
const sources: SessionStartSource[] = []
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
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)
@@ -271,7 +279,7 @@ describe('agent/session-start', () => {
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
})
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -289,7 +297,7 @@ describe('agent/session-start', () => {
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'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
expect(agent.id).toBe(SessionId('a1'))
// and the agent still runs
@@ -303,8 +311,8 @@ describe('agent/session-prefix', () => {
it('dispatches to global and matching agent-scope listeners only', async () => {
const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
const ctx = await harness(adapter)
const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { model: 'mock' })
const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { model: 'mock' })
const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { provider: 'mock', model: 'mock' })
const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { provider: 'mock', model: 'mock' })
const seen: string[] = []
ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
seen.push(`global:${agent.id}`)
@@ -341,7 +349,7 @@ describe('agent/session-prefix', () => {
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'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
let composed = 0
@@ -374,7 +382,7 @@ describe('agent/session-prefix', () => {
it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
const order: string[] = []
@@ -401,7 +409,7 @@ describe('agent/session-prefix', () => {
it('the canonical prepend pattern composes contributions in registration order', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Both listeners use the canonical `[mine, ...await next()]` prepend: the
// waterfall unwinds innermost-first (the second listener's array is built
@@ -423,7 +431,7 @@ describe('agent/session-prefix', () => {
it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A listener that delegates without contributing — the canonical no-op.
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next())
@@ -439,7 +447,7 @@ describe('agent/session-prefix', () => {
it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let mutationError: unknown
ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
@@ -468,7 +476,7 @@ describe('agent/session-prefix', () => {
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'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] }
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...await next(), held])
@@ -489,7 +497,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
it('a continue decision with a reason records next-step steering in the same turn', async () => {
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let forced = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
@@ -521,7 +529,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
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'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' }))
@@ -534,8 +542,8 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
})
})
describe('tools/post-execute additionalContext buffering across a multi-call step', () => {
it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
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 },
@@ -551,11 +559,19 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
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'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Each call attaches additionalContext naming itself.
// Each call attaches one context naming itself.
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } }))
({
kind: 'accept',
additionalContexts: [{
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
source: { kind: 'plugin', plugin: 'p' },
envelope: 'raw',
meta: { callId: exec.callId },
}],
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -575,6 +591,37 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
.map(b => (b.type === 'text' ? b.text : ''))
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
const contextEvents = events(agent).filter(e => e.type === 'context/message')
expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw'])
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: '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(defineTool({
name: 'composite', description: 'composite', parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } })
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 === 'context/message')
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'a' },
{ kind: 'plugin', plugin: 'b' },
])
expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
})
})
@@ -587,7 +634,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
name: 'danger', description: 'danger', parameters: {},
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
@@ -634,7 +681,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
const decision = await next()
if (decision.kind === 'accept') {
return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
return { kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } }] }
}
return decision
})
@@ -649,7 +696,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
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'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'please echo hi')
await waitForIdle(ctx, agent)
@@ -672,7 +719,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
const agent = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' })
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) })
@@ -691,7 +738,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
await fiber.dispose()
// After disposal, a destructive prompt is NOT blocked (the listener is gone).
const agent = ctx.agentLoop.create(SessionId('a3'), { model: 'mock' })
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 prompt-submit listener was disposed