feat(agent): add addressable queue operations

This commit is contained in:
kingwl
2026-07-30 00:05:00 +08:00
parent 87c9ab06b3
commit f893e2281d
108 changed files with 1785 additions and 608 deletions

View File

@@ -11,8 +11,8 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -256,84 +256,112 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
})
}
describe('session/queued frames', () => {
it('forwards live enqueue events and replays the snapshot on a later mux open', async () => {
/** Build one addressable inbox occurrence around a frozen message. */
function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): InboxItem {
return { id: InboxItemId(id), message, placement }
}
describe('session.updateQueue', () => {
it('routes an addressable action and reports a lost claim race', async () => {
const ctx = await harness()
const agent = stubAgent(ctx)
const seen: unknown[] = []
agent.updateInbox = (id, action) => {
seen.push({ id, action })
return id === InboxItemId('present') ? 'applied' : 'not-found'
}
const api = createApiProxy(ctx, DEFAULTS)
const applied = await api.sessions.updateQueue({
rpcId: RpcId('q-apply'),
payload: {
sessionId: agent.id,
itemId: InboxItemId('present'),
action: { kind: 'promote' },
},
})
expect(expectOk(applied)).toEqual({ accepted: true })
const missing = await api.sessions.updateQueue({
rpcId: RpcId('q-missing'),
payload: {
sessionId: agent.id,
itemId: InboxItemId('claimed'),
action: { kind: 'remove' },
},
})
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
expect(seen).toEqual([
{ id: 'present', action: { kind: 'promote' } },
{ id: 'claimed', action: { kind: 'remove' } },
])
})
})
describe('session/queue frames', () => {
it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const live = new AbortController()
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
// subscribed baseline + 2 queued frames
// subscribed baseline + 2 queue snapshots
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
const queued = inboxMessage('m-1', 'queued prompt')
const steering = inboxMessage('m-2', 'queued prompt')
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued')
const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering')
ctx.emit('agent/inbox/enqueue', agent, queued)
ctx.emit('agent/inbox/enqueue', agent, steering)
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue')
expect(liveFrames).toEqual([
{ type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
{ type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
{ type: 'session/queue', sessionId: agent.id, items: [queued] },
{ type: 'session/queue', sessionId: agent.id, items: [queued, steering] },
])
// A fresh mux connection replays the still-pending entries as its baseline.
// A fresh mux connection replays only the current authoritative snapshot.
const replay = new AbortController()
const replayFrames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 3, replay)
expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames)
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]])
})
it('retires mirror entries on their terminal dequeue', async () => {
it('publishes edit and promotion in the authoritative order', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const queued = inboxMessage('m-3', 'x')
const steering = inboxMessage('m-4', 'x', 'r-1')
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
ctx.emit('agent/inbox/dequeue', agent, queued, 'queued')
ctx.emit('agent/inbox/dequeue', agent, steering, 'steering')
const abort = new AbortController()
const frames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort)
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
})
const collected = collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 6, abort)
const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued')
const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued')
const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued')
ctx.emit('agent/inbox/enqueue', agent, first)
ctx.emit('agent/inbox/enqueue', agent, second)
ctx.emit('agent/inbox/update', agent, edited, 'edit')
ctx.emit('agent/inbox/update', agent, edited, 'promote')
ctx.emit('agent/inbox/dequeue', agent, edited)
it('retires the matching placement when one message identity is queued and steering', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const repeated = inboxMessage('m-repeat', 'same prompt')
ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued')
ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering')
ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued')
ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering')
const abort = new AbortController()
const frames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort)
expect(frames.filter(f => f.type === 'session/queued')).toEqual([
{ type: 'session/queued', sessionId: agent.id, message: repeated, steering: false },
const frames = (await collected).filter(frame => frame.type === 'session/queue')
expect(frames.map(frame => frame.items)).toEqual([
[first],
[first, second],
[first, edited],
[edited, first],
[first],
])
})
it('retires mirror entries on a batch discard (cancel path)', async () => {
it('publishes an empty snapshot after terminal discard', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const doomed = inboxMessage('m-5', 'doomed')
const survivor = inboxMessage('m-6', 'survivor')
ctx.emit('agent/inbox/enqueue', agent, doomed, 'queued')
ctx.emit('agent/inbox/enqueue', agent, survivor, 'queued')
const doomed = inboxItem('i-doomed', inboxMessage('m-5', 'doomed'), 'queued')
ctx.emit('agent/inbox/enqueue', agent, doomed)
ctx.emit('agent/inbox/discard', agent, [doomed])
const abort = new AbortController()
const frames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort)
const remaining = frames.filter(f => f.type === 'session/queued')
expect(remaining).toHaveLength(1)
expect(remaining[0]).toMatchObject({ message: survivor })
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort)
expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0)
})
})

View File

@@ -51,6 +51,7 @@ function stubAgent(session: Session): Agent {
steer: () => {},
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -47,6 +47,7 @@ function scriptedApi(overrides: {
selected: { provider: r.payload.provider, model: r.payload.model },
}),
prompt: r => ok(r, { accepted: true as const }),
updateQueue: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
},

View File

@@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async updateQueue(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async cancel(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},

View File

@@ -11,6 +11,7 @@ import {
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema,
} from '../src/api/sessions.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
@@ -68,6 +69,7 @@ describe('rpcErrorSchema', () => {
details: { provider: 'p', model: 'm' },
}).code).toBe('model-unavailable')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found')
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
@@ -219,7 +221,19 @@ describe('sessions domain schemas', () => {
expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' })
expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow()
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionUpdateQueueRequestSchema.parse({
sessionId: 's1',
itemId: 'i1',
action: { kind: 'edit', content: [{ type: 'text', text: 'next' }] },
}).action.kind).toBe('edit')
expect(sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'remove' },
}).action.kind).toBe('remove')
expect(() => sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: '', action: { kind: 'promote' },
})).toThrow()
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })
})
})
@@ -363,8 +377,10 @@ describe('events frame schemas', () => {
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
{ type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
{ type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
{ type: 'session/queue', sessionId: 's', items: [
{ id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, placement: 'queued' },
{ id: 'i2', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, placement: 'steering' },
] },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
@@ -382,10 +398,10 @@ describe('events frame schemas', () => {
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
})
it('rejects a queued frame missing its members', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow()
it('rejects a queue snapshot with malformed items', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {}, placement: 'queued' }] })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} }, placement: 'later' }] })).toThrow()
})
it('accepts every host frame branch', () => {