fix inbox lifecycle downstream contracts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
|
||||
README.md: 8942dcd976f9c7c5a8109ad3979fefe2a913cba5
|
||||
README.zh.md: 274a37556a9c2af140c57e5346da8abf7bc49bf3
|
||||
README.md: e1f1b121787645930fa9c41b3d0d5ee9880ef4ad
|
||||
README.zh.md: becc0ae299269e9982d6a63d7a2df966b999cfa9
|
||||
|
||||
@@ -62,7 +62,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `update`, `remove`, and `splice` mutate them; ordinary removals are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
|
||||
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `update`, `remove`, `clear`, and `splice` mutate them; ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
|
||||
- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`.
|
||||
- `agent.steer(message)` — queue waking `next-step` input. An idle driver schedules a turn; collecting and running drivers consume it at their next step boundary.
|
||||
- `agent.inject(message)` — queue non-waking `next-step` context. A collecting or running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch.
|
||||
|
||||
@@ -62,7 +62,7 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
|
||||
|
||||
每个插件面向的 handle:
|
||||
|
||||
- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值。`append`、`prepend`、`update`、`remove` 与 `splice` 用于变更队列;普通删除是持久取消,并发出 `agent/inbox/discarded`。`claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed`。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
|
||||
- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值。`append`、`prepend`、`update`、`remove`、`clear` 与 `splice` 用于变更队列;普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded`。`claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed`。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
|
||||
- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle;消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`。
|
||||
- `agent.steer(message)`:将会唤醒的 `next-step` 输入排队。空闲驱动器会调度一个轮次;collecting 和 running 驱动器会在各自的下一步骤边界消费该输入。
|
||||
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它;idle 驱动器则会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。
|
||||
|
||||
@@ -54,6 +54,12 @@ export class Inbox {
|
||||
return this.nextTurn.length > 0 || this.nextStep.length > 0
|
||||
}
|
||||
|
||||
/** Durably cancel all pending input, clearing next-step before next-turn. */
|
||||
clear(): void {
|
||||
this.splice('next-step', 0, this.nextStep.length, [])
|
||||
this.splice('next-turn', 0, this.nextTurn.length, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the complete batch proposed for one step. The durable
|
||||
* splices are pure deletions; the caller publishes claimed notifications.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, {
|
||||
agentEvents,
|
||||
Inbox,
|
||||
@@ -34,6 +35,69 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
return Object.assign(agent, overrides)
|
||||
}
|
||||
|
||||
describe('Inbox', () => {
|
||||
it('rejects an invalid durable splice during reconstruction', () => {
|
||||
const session = new Session(SessionId('invalid-inbox-replay'))
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-turn',
|
||||
start: 1,
|
||||
inserted: [],
|
||||
})
|
||||
|
||||
expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {} }))
|
||||
.toThrow('invalid persisted inbox splice at session seq 0')
|
||||
})
|
||||
|
||||
it('updates a pending message by identity and reports a missing identity', () => {
|
||||
const session = new Session(SessionId('update-inbox'))
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
|
||||
const original = createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const replacement = freezeMessage({
|
||||
...original,
|
||||
content: [{ type: 'text', text: 'replacement' }],
|
||||
})
|
||||
inbox.append('next-turn', original)
|
||||
|
||||
expect(inbox.update('next-turn', createUserMessage({
|
||||
content: [{ type: 'text', text: 'missing' }],
|
||||
source: { kind: 'user' },
|
||||
}).id, replacement)).toBe(false)
|
||||
expect(inbox.update('next-turn', original.id, replacement)).toBe(true)
|
||||
expect(inbox.nextTurn).toEqual([replacement])
|
||||
})
|
||||
|
||||
it('clears both pending lists as durable cancellations', () => {
|
||||
const session = new Session(SessionId('clear-inbox'))
|
||||
const discarded: UserMessage[] = []
|
||||
const inbox = new Inbox(session, {
|
||||
inserted: () => {},
|
||||
discarded: message => void discarded.push(message),
|
||||
})
|
||||
const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
|
||||
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
|
||||
inbox.append('next-turn', nextTurn)
|
||||
inbox.append('next-step', nextStep)
|
||||
const beforeClear = session.events.length
|
||||
|
||||
inbox.clear()
|
||||
|
||||
expect(inbox.hasPending).toBe(false)
|
||||
expect(discarded).toEqual([nextStep, nextTurn])
|
||||
expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
|
||||
? event.data
|
||||
: event.type)).toEqual([
|
||||
{ target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
|
||||
{ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
|
||||
])
|
||||
|
||||
inbox.clear()
|
||||
expect(session.events).toHaveLength(beforeClear + 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -184,6 +248,21 @@ describe('agentEvents()', () => {
|
||||
'agent event "agent/status" listener rejected: Error: async listener',
|
||||
])
|
||||
})
|
||||
|
||||
it('dispatches serial listeners with the fused agent subject', async () => {
|
||||
const ctx = new Context()
|
||||
const agent = stubAgent('serial-event')
|
||||
const signal = new AbortController().signal
|
||||
const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = []
|
||||
ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => {
|
||||
await Promise.resolve()
|
||||
heard.push({ agent: subject, turn, signal: receivedSignal })
|
||||
})
|
||||
|
||||
await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal)
|
||||
|
||||
expect(heard).toEqual([{ agent, turn: 3, signal }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('explicit cancellation contract', () => {
|
||||
|
||||
Reference in New Issue
Block a user