Merge remote-tracking branch 'origin/feat/send-unify' into worktree/agent-message-intents

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	docs/cordis-catalog/events.md
#	docs/event-producer-consumer.md
#	packages/core/agent-loop/src/agent.ts
This commit is contained in:
Tianyi Cui
2026-07-24 13:59:37 +08:00
18 changed files with 174 additions and 85 deletions

View File

@@ -132,6 +132,27 @@ describe('Agent', () => {
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
it('disposal discards still-pending inbox items so every id gets a terminal event', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: Agent
const discarded: string[] = []
ctx.on('agent/inbox/discard', (subject, messages) => {
if (subject === agent) discarded.push(...messages.map(m => m.id))
})
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
// A quiet (non-waking) item stays parked in the inbox; disposal must drop it
// WITH a discard so its enqueued id is not left dangling forever.
const id = agent.queue([{ type: 'text', text: 'never runs' }])
await fiber.dispose()
await driverDone(agent)
expect(discarded).toEqual([id])
})
it('steer() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -211,24 +232,54 @@ describe('Agent', () => {
warn.mockRestore()
})
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Non-serializable injected content makes Session.append throw AFTER
// turn/start was recorded. The turn/end must still be appended (finally),
// AND the durability checkpoint must still fire — the balanced turn is in
// memory and a crash before the next turn/dispose would otherwise lose it.
// Non-serializable injected content is rejected by the up-front snapshot
// BEFORE any append (the unified send contract: invalid input throws before
// mutating the log). No one-shot turn opens and no durability checkpoint fires.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
const types = agent.session.events.map(e => e.type)
expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn
await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run
expect(flushes).toBe(1) // checkpoint fired despite the throw
}).toThrow(/losslessly JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance
expect(flushes).toBe(0) // nothing was appended, so no checkpoint
})
it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Injecting from inside a session/event listener re-enters Session.append,
// which rejects pre-commit — so turn/start never commits. The finally sees
// no open turn (closes nothing) and no recorded turn (no checkpoint), and
// the reentrant throw is contained by Session's post-commit dispatch.
// Fire on turn/end: at that instant the outer one-shot turn is closed (no
// turn open), so the reentrant inject takes the idle one-shot-turn path and
// its turn/start append re-enters Session and is rejected pre-commit.
let reentered = false
ctx.on('session/event', (_s, event) => {
if (!reentered && event.type === 'turn/end') {
reentered = true
agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } })
}
})
agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } })
// The outer injection's own one-shot turn is balanced; the reentrant one
// opened no turn (its turn/start was rejected pre-commit).
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
expect(turnStarts).toHaveLength(1)
const injected = agent.session.events.filter(e => e.type === 'user/message')
expect(injected).toHaveLength(1) // the reentrant user/message never committed
await new Promise(r => setTimeout(r, 10))
expect(flushes).toBe(1) // only the outer accepted turn checkpointed
})
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
@@ -278,13 +329,11 @@ describe('Agent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
// the log stays empty, not left with a dangling turn/start.
// A non-serializable source is rejected by the up-front snapshot BEFORE any
// append, so NO turn opens and the log stays empty.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/non-JSON-serializable/)
}).toThrow(/losslessly JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
})

View File

@@ -1,11 +1,20 @@
import { describe, expect, it } from 'vitest'
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
import { Inbox } from '../src/inbox.ts'
import { Inbox, agentMessage } from '../src/inbox.ts'
function message(text: string) {
return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true }
}
describe('agentMessage', () => {
it('returns a frozen payload so a listener cannot mutate it for later listeners', () => {
const payload = agentMessage(message('m'), false)
expect(Object.isFrozen(payload)).toBe(true)
expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow()
expect(payload.id).toBe(AgentMessageId('m'))
})
})
function resolverPair() {
let r!: () => void
const p = new Promise<void>((resolve) => { r = resolve })