feat(agent): unify send(target × wakeup), coalesce context/message into user/message

Replace send/steer/inject with one Agent.send primitive over the
(target × wakeup) matrix; followup/steer/inject become fixed-preset
alias methods on the now-abstract Agent class. Coalesce context/message
into user/message (injected context is a non-user source). Replace
agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel
keepInbox, and add a FIFO-conservation invariant.
This commit is contained in:
Turtle
2026-07-23 19:15:45 +08:00
parent 7c0c516f60
commit 44fd93fd06
117 changed files with 1249 additions and 728 deletions

View File

@@ -3,26 +3,28 @@ import { Context, Service, symbols } from 'cordis'
import type { Events } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, {
Agent,
agentEvents,
agentInterruptReasonOf,
} from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
const id = SessionId(rawId)
return {
// Agent is an abstract class, so its alias methods live on the prototype and
// object spread would drop them; build the full literal and merge overrides.
return Object.assign(Object.create(Agent.prototype) as Agent, {
id,
options: {},
session: new Session(id),
status: 'idle',
ctx: new Context(),
send() {},
steer() {},
inject() {},
cancel() {},
whenIdle() { return Promise.resolve() },
}
...overrides,
})
}
describe('AgentRegistry', () => {
@@ -56,7 +58,7 @@ describe('AgentRegistry', () => {
it('rejects an agent whose registry and session identities differ', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) }
const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) })
expect(() => ctx.agents.enter(agent, undefined))
.toThrow('agent id "agent-id" does not match session id "session-id"')

View File

@@ -56,3 +56,41 @@ describe('agent status invariants', () => {
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
})
})
describe('agent inbox invariants', () => {
const info = (steering: boolean) => ({ content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true })
it('accepts a dequeue and a discard covered by prior enqueues', async () => {
const ctx = await setup()
const agent = mockAgent('i1')
const at = scopeTarget(agent, agent)
expect(() => {
ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
ctx.emit(at, 'agent/inbox/enqueue', agent, info(true))
ctx.emit(at, 'agent/inbox/dequeue', agent, info(false))
ctx.emit(at, 'agent/inbox/discard', agent, [info(true)])
}).not.toThrow()
})
it('rejects a dequeue with no outstanding item', async () => {
const ctx = await setup()
const agent = mockAgent('i2')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(false)) })
.toThrow(/without a matching prior enqueue/)
})
it('rejects a discard larger than the outstanding count', async () => {
const ctx = await setup()
const agent = mockAgent('i3')
const at = scopeTarget(agent, agent)
ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(false), info(true)]) })
.toThrow(/dropped 2 items but only 1 were outstanding/)
})
it('accepts an empty discard against a fresh agent', async () => {
const ctx = await setup()
const agent = mockAgent('i4')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow()
})
})