Files
creatixchu f1606e31d2 test(web): 提交回显的组装路径 e2e 与不可见标记
PendingSubmissionBubble 携带 data-submission-echo 标记(渲染不变,仅供检测),
新增 keyless 组装 e2e:发送按键当下回显即在流中、composer 已清空可编辑,
durable 节点到达后原位替换且只剩一条气泡。
2026-08-26 12:12:08 +08:00

145 lines
5.6 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { describe, expect, it } from 'vitest'
import { SessionControlController } from '../src/control.ts'
async function harness(): Promise<{
ctx: Context
control: SessionControlController
agent: Agent
inbox: Inbox
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('queue-session'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent = { id: session.id, session, inbox, status: 'running', ctx } as Agent
ctx.agents.register(agent)
return { ctx, control: new SessionControlController(ctx), agent, inbox }
}
function message(text: string, source: 'user' | 'plugin' = 'user') {
return createUserMessage({
content: [{ type: 'text', text }],
source: source === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin: 'fixture' },
})
}
describe('Session control queue projection', () => {
it('projects both pending lists in baselines and live replacement frames', async () => {
const { control, inbox } = await harness()
const queued = message('queued')
const steering = message('steering')
const context = message('context', 'plugin')
inbox.append('next-turn', queued)
inbox.append('next-step', steering)
inbox.append('next-step', context)
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
const opened = await iterator.next()
expect(opened.value).toMatchObject({
type: 'baseline',
value: {
queues: {
'queue-session': [
{ id: queued.id, placement: 'queued' },
{ id: steering.id, placement: 'steering' },
{ id: context.id, placement: 'context' },
],
},
},
})
const replacement = message('replacement')
inbox.append('next-turn', replacement)
const replaced = await iterator.next()
if (replaced.done || replaced.value.type !== 'queue') throw new Error('missing queue replacement')
expect(replaced.value.items.map(item => item.id)).toContain(replacement.id)
inbox.remove(steering.id)
const removed = await iterator.next()
if (removed.done || removed.value.type !== 'queue') throw new Error('missing queue replacement')
expect(removed.value.items.map(item => item.id)).not.toContain(steering.id)
abort.abort()
await iterator.next()
})
it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => {
const { control, inbox } = await harness()
const identified = createUserMessage({
content: [{ type: 'text', text: 'browser prompt' }],
source: { kind: 'user', rpcId: 'req-42' as never },
})
inbox.append('next-turn', identified)
inbox.append('next-step', message('plain steering'))
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
const opened = await iterator.next()
if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline')
const items = opened.value.value.queues['queue-session' as SessionId] ?? []
expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([
{ id: identified.id, placement: 'queued', rpcId: 'req-42' },
{ id: items[1]?.id, placement: 'steering', rpcId: undefined },
])
expect('rpcId' in (items[1] ?? {})).toBe(false)
abort.abort()
await iterator.next()
})
it('ignores inbox events without the exact live Agent session', async () => {
const { ctx, control, agent, inbox } = await harness()
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
await iterator.next()
const unrelated = ctx.sessions.create(SessionId('unrelated-queue'))
unrelated.append('agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [message('unrelated')],
})
const replacement = ctx.sessions.create(SessionId('replacement-session'))
Object.defineProperty(agent, 'session', { configurable: true, value: replacement })
inbox.append('next-turn', message('wrong-session'))
abort.abort()
await iterator.next()
})
it('drops broadcasts after cancellation has ended its queue', async () => {
const { control, inbox } = await harness()
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
await iterator.next()
const waiting = iterator.next()
await Promise.resolve()
abort.abort()
inbox.append('next-turn', message('late'))
await expect(waiting).resolves.toMatchObject({ done: true })
})
it('ends active streams on context disposal after flushing buffered frames', async () => {
const { ctx, control, inbox } = await harness()
const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]()
await iterator.next()
inbox.append('next-turn', message('first'))
inbox.append('next-turn', message('second'))
const first = await iterator.next()
expect(first).toMatchObject({ done: false, value: { type: 'queue' } })
await ctx.fiber.dispose()
const second = await iterator.next()
expect(second).toMatchObject({ done: false, value: { type: 'queue' } })
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
})