| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- /** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
- import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
- import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types'
- import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
- import { Session } from '../src/client/sessions/session.ts'
- import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
- import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts'
- import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
- import { historyValue } from './event-script.client.ts'
- const SID = 'fk-s1' as SessionId
- afterEach(() => {
- vi.unstubAllGlobals()
- })
- function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
- return { api, session: new Session(SID, fakeRemote(api)) }
- }
- function imageRef(id: string): ImageAttachmentRef {
- return {
- attachmentId: id,
- mediaType: 'image/png',
- bytes: 1,
- width: 2,
- height: 2,
- } as unknown as ImageAttachmentRef
- }
- function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef {
- return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef
- }
- 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
- }
- function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem {
- return {
- id: 'm-queued' as SessionQueuedItem['id'],
- placement: 'queued',
- rpcId,
- message: {
- id: 'm-queued' as SessionQueuedItem['id'],
- content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'],
- },
- }
- }
- /** 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', () => {
- const { session } = makeSession()
- 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 },
- }],
- }])
- })
- it('derives and captures the echo placement from running state and delivery mode', () => {
- const { session } = makeSession()
- 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', () => {
- const { session } = makeSession()
- 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 () => {
- const { api, session } = makeSession()
- api.onPrompt = () => Promise.resolve(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' })
- expect(retirements).toEqual([{ reason: 'failed' }])
- })
- it('sends the echo identity as the prompt requestId', async () => {
- const { api, session } = makeSession()
- const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] })
- await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
- expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
- })
- it('an unidentified prompt failure leaves registered echoes alone', async () => {
- const { api, session } = makeSession()
- api.onPrompt = () => Promise.resolve(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 () => {
- const { api, session } = makeSession()
- api.onHistory = () => Promise.resolve(ok(historyValue([])))
- 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 api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never })
- // 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 () => {
- const { session } = makeSession()
- 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({ type: 'queue', sessionId: SID, items: [queuedItem(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 () => {
- const { api, session } = makeSession()
- api.onHistory = () => Promise.resolve(ok(historyValue([])))
- 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 api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never })
- await settleFrames()
- expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
- })
- it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
- const { api, session } = makeSession()
- const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] })
- api.onHistory = () => Promise.resolve(ok(historyValue([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 () => {
- const { api, session } = makeSession()
- api.onHistory = () => Promise.resolve(ok(historyValue([])))
- await session.open()
- const retirements: PendingSubmissionRetirement[] = []
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '先观察',
- attachments: [],
- onRetire: retirement => retirements.push(retirement),
- })
- await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never })
- 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 () => {
- const { api, session } = makeSession()
- api.onHistory = () => Promise.resolve(ok(historyValue([])))
- await session.open()
- const retirements: PendingSubmissionRetirement[] = []
- const handle = session.beginSubmission({
- mode: 'queue',
- text: '同一请求',
- attachments: [],
- onRetire: retirement => retirements.push(retirement),
- })
- session.handleControlFrame({
- type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])],
- })
- await api.pushFollow(SID, {
- type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never,
- })
- 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 () => {
- const frames: FrameRequestCallback[] = []
- vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
- frames.push(fn)
- return frames.length
- })
- const { api, session } = makeSession()
- api.onHistory = () => Promise.resolve(ok(historyValue([])))
- await session.open()
- const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
- await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never })
- 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 () => {
- const { api, session } = makeSession()
- api.onHistory = () => Promise.resolve(ok(historyValue([])))
- 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 api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never })
- await session.dispose()
- await settleFrames()
- expect(retirements).toEqual([
- { text: '未settle', retirement: { reason: 'failed' } },
- { text: '已观察', retirement: { reason: 'observed', attachments: [] } },
- ])
- expect(session.getSnapshot().pendingSubmissions).toEqual([])
- })
- })
|