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

@@ -57,7 +57,6 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
break
}
case 'tool/result':
case 'context/message':
break
/* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */
default:

View File

@@ -61,7 +61,7 @@ function appendConversation(session: Session): void {
{ surfaceOp: 'append' },
)
session.append(
'context/message',
'user/message',
{ content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'plugin', plugin: 'workspace' } },
{ surfaceOp: 'append' },
)

View File

@@ -64,7 +64,6 @@ function precedingMessageTime(agent: Agent): number | undefined {
case 'user/message':
case 'assistant/message':
case 'tool/result':
case 'context/message':
case 'steering/message':
return event.time
default:
@@ -79,7 +78,7 @@ function precedingMessageTime(agent: Agent): number | undefined {
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
if (event.type === 'context/message'
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
@@ -91,7 +90,7 @@ function precedingStepContextTime(agent: Agent, turn: number): number | undefine
/** Find this plugin's latest durable injection, including a shadowed surface event. */
function latestInjectionTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'context/message'
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time

View File

@@ -48,7 +48,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
/** Validate one plugin-attributed time reading against its session position and timestamp. */
function validateReading(
history: readonly SessionEvent[],
event: SessionEvent<'context/message'>,
event: SessionEvent<'user/message'>,
fail: InvariantFailure,
): void {
const [block] = event.data.content
@@ -84,7 +84,7 @@ function validateReading(
/** Validate all package-owned readings already present in one session. */
function validateSession(session: Session, fail: InvariantFailure): void {
for (const [index, event] of session.events.entries()) {
if (event.type !== 'context/message'
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== SOURCE_NAME) continue
validateReading(session.events.slice(0, index), event, fail)
@@ -97,7 +97,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'context/message'
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== SOURCE_NAME) return
validateReading(session.events, event, fail)

View File

@@ -17,7 +17,7 @@ async function setup(): Promise<Context> {
function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent {
return {
type: 'context/message',
type: 'user/message',
seq: 0,
time,
data: {
@@ -56,7 +56,7 @@ function preparing(turn: number, step: number): Session {
}
function appendReading(session: Session, text: string): void {
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'time-context' },
}, { surfaceOp: 'append' })
@@ -162,7 +162,7 @@ describe('time-context invariants', () => {
it('ignores context messages owned by another package', async () => {
const ctx = await setup()
const other = event('unrelated') as SessionEvent<'context/message'>
const other = event('unrelated') as SessionEvent<'user/message'>
other.data.source = { kind: 'plugin', plugin: 'other' }
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
other.data.source = { kind: 'user' }

View File

@@ -48,7 +48,7 @@ describe('time-context through a real headless cordis.yml', () => {
expect(stderr).not.toContain('UNHANDLED')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
const contexts = events.filter(event => event.type === 'context/message')
const contexts = events.filter(event => event.type === 'user/message')
const starts = events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(2)
expect(starts).toHaveLength(2)

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -43,9 +43,10 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
status: 'running',
ctx: new Context(),
send() {},
followup() {},
steer() {},
inject(content, options) {
session.append('context/message', {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
}, { surfaceOp: 'append' })
@@ -66,7 +67,7 @@ function openMessageTurn(session: Session, turn: number): void {
function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.events) {
if (event.type === 'context/message'
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
@@ -151,8 +152,8 @@ describe('durable step context', () => {
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
])
const event = session.events.at(-1)
expect(event?.type).toBe('context/message')
if (event?.type !== 'context/message') throw new Error('missing time context')
expect(event?.type).toBe('user/message')
if (event?.type !== 'user/message') throw new Error('missing time context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
expect(event.surfaceOp).toBe('append')
})
@@ -230,10 +231,10 @@ describe('durable step context', () => {
const original = new Session(SessionId('seed-source'))
openMessageTurn(original, 1)
await fire(ctx, sessionAgent(original), 1, 1)
const user = original.events.find(event => event.type === 'user/message')
const reading = original.events.find(event => event.type === 'context/message')
const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
original.append('context/message', {
original.append('user/message', {
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact-basic' },
}, {
@@ -292,7 +293,7 @@ describe('durable step context', () => {
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
await fire(ctx, agent, 1, 1)
@@ -401,7 +402,8 @@ describe('real agent-loop request history', () => {
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
const contexts = agent.session.events.filter(event => event.type === 'context/message')
const contexts = agent.session.events.filter(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
const starts = agent.session.events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(adapter.requests.length)
expect(starts).toHaveLength(adapter.requests.length)

View File

@@ -145,7 +145,7 @@ function visibleInstructionChanges(
const visibleSeqs = new Set(agent.session.surface.nodes)
const visible = new Map<string, WorkspaceInstructionChange>()
for (const [seq, event] of agent.session.events.entries()) {
if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.meta)
for (const change of changes) {
const waiting = pending.get(change.scope)
@@ -281,7 +281,7 @@ export function observeInstructionSessionEvent(
if (pending === undefined) return
switch (event.type) {
case 'context/message': {
case 'user/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.meta)) {
const waiting = pending.get(change.scope)

View File

@@ -107,15 +107,15 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
await waitForIdle(live.ctx, live.agent)
const events = [...live.agent.session.events]
const update = events.find(event => event.type === 'context/message'
const update = events.find(event => event.type === 'user/message'
&& typeof event.data.meta === 'object'
&& event.data.meta !== null
&& !Array.isArray(event.data.meta)
&& event.data.meta.kind === 'workspace-instructions')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
const updateText = update?.type === 'context/message'
const updateText = update?.type === 'user/message'
? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
: ''
expect(updateText).toContain('Updated instructions from: AGENTS.md')

View File

@@ -175,9 +175,10 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
session,
status: 'idle',
send() {},
followup() {},
steer() {},
inject(content, options) {
session.append('context/message', {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
...options?.meta !== undefined ? { meta: options.meta } : {},
@@ -219,7 +220,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext {
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined {
let lastSeq: number | undefined
for (const context of result.additionalContexts ?? []) {
lastSeq = agent.session.append('context/message', {
lastSeq = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
@@ -971,7 +972,7 @@ describe('workspace context request injection', () => {
const second = await composeBaselinePrefix(ctx, agent)
expect(second).toEqual(first)
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(derivedText(agent)).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1143,7 +1144,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(derivedText(agent)).not.toContain('workspace-context:')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1711,12 +1712,12 @@ describe('dynamic nested workspace context injection', () => {
agent.send([{ type: 'text', text: 'read and abort' }])
await agent.whenIdle()
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
agent.send([{ type: 'text', text: 'retry the read' }])
await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'context/message')
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
// The aborted batch drained its accepted context before step close, so the
// retry sees durable history without producing a duplicate instruction.
expect(contexts).toHaveLength(1)
@@ -2490,10 +2491,7 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
appendAdditionalContexts(agent, first)
const resumed = {
...agent,
session: new Session(agent.session.id, [...agent.session.events], agent.session.header),
}
const resumed = stubAgent(root, [...agent.session.events])
const afterResume = await ctx.tools.execute({
signal: testToolSignal,
@@ -2531,11 +2529,11 @@ describe('dynamic nested workspace context injection', () => {
await composeBaselinePrefix(ctx, resumed)
const update = resumed.session.events.findLast(event => event.type === 'context/message')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2681,7 +2679,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
agent.session.append('context/message', {
agent.session.append('user/message', {
content: [
{ type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
@@ -2698,12 +2696,12 @@ describe('dynamic nested workspace context injection', () => {
],
},
}, { surfaceOp: 'append' })
agent.session.append('context/message', {
agent.session.append('user/message', {
content: [{ type: 'text', text: 'stale metadata version' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: { kind: 'workspace-instructions', version: 0, changes: [] },
}, { surfaceOp: 'append' })
agent.session.append('context/message', {
agent.session.append('user/message', {
content: [{ type: 'text', text: 'foreign plugin context' }],
source: { kind: 'plugin', plugin: 'other' },
meta: {
@@ -3216,14 +3214,14 @@ describe('workspace context pending state', () => {
path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
}]]))
const unrelated = agent.session.append('context/message', {
const unrelated = agent.session.append('user/message', {
content: [], source: { kind: 'plugin', plugin: 'other' },
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, unrelated, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const otherContext = workspaceChangeContext('other', 'other')
const otherWorkspaceEvent = agent.session.append('context/message', {
const otherWorkspaceEvent = agent.session.append('user/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
@@ -3232,7 +3230,7 @@ describe('workspace context pending state', () => {
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const context = workspaceChangeContext('pkg', 'one')
const confirmed = agent.session.append('context/message', {
const confirmed = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},