| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- /**
- * Local submission echoes: synchronous insertion, observed/failed retirement,
- * and settlement callbacks. Prompts and the follow stream cross the assembled
- * Gateway client and are answered by endpoint name.
- */
- import { afterEach, describe, expect, vi } from 'vitest'
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
- import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
- import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
- import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
- import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
- import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
- import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
- import type { SessionRequestId } from '../src/types.ts'
- import { sessionBench } from './remote/bench.client.ts'
- import {
- FOLLOW, err, fileRef, followScript, history, imageRef, pushEvent, queueFrame,
- } from './remote/session.client.ts'
- /** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
- const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
- const it = createClientTest({ roster: API_ROSTER })
- const SID = 'fk-s1' as SessionId
- /** The first client boot pays the cold module transform of the api cone. */
- const COLD_BOOT_TIMEOUT_MS = 60_000
- afterEach(() => {
- vi.unstubAllGlobals()
- })
- type AttachmentRef = ImageAttachmentRef | FileAttachmentRef
- function attachmentBlock(attachment: AttachmentRef) {
- return 'mediaType' in attachment
- ? { type: 'image' as const, attachment }
- : { type: 'file' as const, attachment }
- }
- /** A durable browser-prompt user/message whose source echoes `rpcId`. */
- function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionEvent {
- return {
- seq,
- time: 1_700_000_000_000 + seq,
- type: 'user/message',
- surfaceOp: 'append',
- data: createUserMessage({
- content: [
- ...refs.map(attachmentBlock),
- { type: 'text' as const, text: '发送' },
- ],
- source: { kind: 'user', rpcId },
- }),
- } as unknown as SessionEvent
- }
- /** The Host's queue holding one occurrence of the prompt `rpcId`. */
- function queuedFrame(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) {
- return queueFrame(SID, [{ id: 'm-queued', rpcId, content: refs.map(attachmentBlock) }])
- }
- /** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */
- function settleFrames(): Promise<void> {
- return new Promise(resolve => setTimeout(resolve, 0))
- }
- describe('beginSubmission', () => {
- it('inserts the echo synchronously and flips the engaging edge before any prompt call', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '你好',
- attachments: [{
- type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
- }],
- })
- expect(session.getSnapshot().promptAttempted).toBe(true)
- expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
- requestId: handle.requestId,
- placement: 'transcript',
- text: '你好',
- attachments: [{
- type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
- }],
- }])
- expect(mock.log.requests()).toEqual([])
- }, COLD_BOOT_TIMEOUT_MS)
- it('derives and captures the echo placement from running state and delivery mode', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] })
- session.handleRunning(true)
- session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] })
- session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] })
- session.handleRunning(false)
- expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([
- { text: '空闲', placement: 'transcript' },
- { text: '排队', placement: 'queued' },
- { text: '纠偏', placement: 'steering' },
- ])
- })
- it('abandon retires the echo as failed exactly once', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- const retirements: PendingSubmissionRetirement[] = []
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '放弃',
- attachments: [],
- onRetire: retirement => retirements.push(retirement),
- })
- handle.abandon()
- handle.abandon()
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- expect(retirements).toEqual([{ reason: 'failed' }])
- })
- })
- describe('prompt-coupled retirement', () => {
- it('a rejected identified prompt retires its echo immediately alongside promptError', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
- const retirements: PendingSubmissionRetirement[] = []
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '失败的',
- attachments: [],
- onRetire: retirement => retirements.push(retirement),
- })
- const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
- expect(result.ok).toBe(false)
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } })
- expect(retirements).toEqual([{ reason: 'failed' }])
- })
- it('sends the echo identity as the prompt requestId', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] })
- await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
- expect(mock.log.requests('session/prompt')).toMatchObject([{ requestId: handle.requestId, sessionId: SID }])
- })
- it('an unidentified prompt failure leaves registered echoes alone', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
- session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] })
- await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
- expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
- })
- })
- describe('observed retirement', () => {
- it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- await session.open()
- const retirements: PendingSubmissionRetirement[] = []
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '发送',
- attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
- onRetire: retirement => retirements.push(retirement),
- })
- const refs = [imageRef('att-1')]
- await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs))
- // Synchronously after the append the echo is still in the snapshot; the
- // render-time dedupe owns the overlap frame.
- expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
- await settleFrames()
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
- })
- it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- const retirements: PendingSubmissionRetirement[] = []
- session.handleRunning(true)
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '排队',
- attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
- onRetire: retirement => retirements.push(retirement),
- })
- const refs = [imageRef('att-q')]
- session.handleControlFrame(queuedFrame(handle.requestId, refs))
- await settleFrames()
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
- // The queue projection keeps the correlation id for render-time dedupe.
- expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
- })
- it('retires a mixed echo with durable references in original selection order', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- await session.open()
- const retirements: PendingSubmissionRetirement[] = []
- const file = fileRef('file-1')
- const handle = session.beginSubmission({
- mode: 'queue',
- text: 'mixed',
- attachments: [
- { type: 'image', value: { previewUrl: 'blob:first' } },
- { type: 'file', value: file },
- { type: 'image', value: { previewUrl: 'blob:last' } },
- ],
- onRetire: retirement => retirements.push(retirement),
- })
- const refs = [imageRef('image-1'), file, imageRef('image-2')]
- await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs))
- await settleFrames()
- expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
- })
- it('a full-window install (reconnect resync) retires echoes observed in the window', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] })
- mock.stream(FOLLOW, followScript(history([promptEvent(SessionSeq(12), handle.requestId)])))
- await session.open()
- await settleFrames()
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- })
- it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- await session.open()
- const retirements: PendingSubmissionRetirement[] = []
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '先观察',
- attachments: [],
- onRetire: retirement => retirements.push(retirement),
- })
- await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
- handle.abandon()
- await settleFrames()
- expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
- })
- it('retires once when the queue and durable event report the same request id', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- await session.open()
- const retirements: PendingSubmissionRetirement[] = []
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '同一请求',
- attachments: [],
- onRetire: retirement => retirements.push(retirement),
- })
- session.handleControlFrame(queuedFrame(handle.requestId))
- await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
- await settleFrames()
- expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- })
- it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- await session.open()
- const frames: FrameRequestCallback[] = []
- vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
- frames.push(fn)
- return frames.length
- })
- const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
- await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
- expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
- expect(frames).toHaveLength(1)
- frames[0]?.(0)
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- })
- })
- describe('disposal', () => {
- it('retires unsettled echoes as failed and preserves an already-observed settlement', async ({ mock, start }) => {
- const session = await sessionBench(mock, start, SID)
- await session.open()
- const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
- const observed = session.beginSubmission({
- mode: 'queue',
- text: '已观察',
- attachments: [],
- onRetire: retirement => retirements.push({ text: '已观察', retirement }),
- })
- session.beginSubmission({
- mode: 'queue',
- text: '未settle',
- attachments: [],
- onRetire: retirement => retirements.push({ text: '未settle', retirement }),
- })
- await pushEvent(mock, promptEvent(SessionSeq(0), observed.requestId))
- await session.dispose()
- await settleFrames()
- expect(retirements).toEqual([
- { text: '未settle', retirement: { reason: 'failed' } },
- { text: '已观察', retirement: { reason: 'observed', attachments: [] } },
- ])
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- })
- })
|