| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- /**
- * Reference-submit transaction coverage: chips serialize through their
- * owner, stay resident through Host rejection, and clear only after an
- * accepted prompt.
- */
- import { describe, expect, it, vi } from 'vitest'
- import type { Context } from '@deepseek-ai/cordis'
- import type { InputTriggerController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
- import { SessionInputShell } from '../src/client/input/facade.ts'
- import type { DraftAttachmentId } from '../src/client/contract/input.ts'
- const mention = '@[Research](dsh-session:InNvdXJjZSI)'
- const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)'
- const commandImages = {
- serialize: () => Promise.resolve([]),
- release: () => {},
- unsupportedNotice: (token: string) => `${token.trim()} images-unsupported`,
- }
- function chip(shell: SessionInputShell): void {
- shell.setDraft('@res')
- const accepted = shell.insertReference({
- source: 'reference',
- ref: mention,
- label: 'Research',
- clipboardText: mention,
- }, {
- start: 0,
- end: 4,
- draftRev: shell.snapshot.draftRev,
- })
- expect(accepted).toBe(true)
- }
- describe('reference submission', () => {
- it('mirrors canonical reference text so a persisted draft remains resolvable after remount', async () => {
- const mirror = vi.fn()
- const first = new SessionInputShell({
- actx: {} as Context,
- defaultSink: vi.fn(),
- commandImages,
- })
- first.bindMirror(mirror)
- first.setDraft('@res')
- expect(first.insertReference({
- source: 'reference',
- ref: spacedMention,
- label: 'Research notes',
- appearance: 'session',
- clipboardText: spacedMention,
- }, {
- start: 0,
- end: 4,
- draftRev: first.snapshot.draftRev,
- })).toBe(true)
- expect(first.snapshot.draft).toBe('@Research notes ')
- expect(mirror).toHaveBeenLastCalledWith(`${spacedMention} `)
- const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
- const restored = new SessionInputShell({
- actx: {} as Context,
- defaultSink: sink,
- commandImages,
- })
- restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string)
- restored.submit()
- await vi.waitFor(() => {
- expect(sink).toHaveBeenCalledWith(spacedMention, [], 'queue', expect.any(AbortSignal))
- })
- })
- it('retains the chip on Host failure and clears it only after a later accepted retry', async () => {
- const serializeReference = vi.fn(() => Promise.resolve(mention))
- const sink = vi.fn<(
- _text: string,
- _imageIds: readonly DraftAttachmentId[],
- _mode: 'queue' | 'steer',
- _signal: AbortSignal,
- ) => Promise<SubmitOutcome>>()
- .mockResolvedValueOnce({ kind: 'error', text: 'snapshot unavailable' })
- .mockResolvedValueOnce({ kind: 'success' })
- const inputTriggers = {
- serializeReference,
- track: vi.fn(),
- } as unknown as InputTriggerController
- const shell = new SessionInputShell({
- actx: {} as Context,
- inputTriggers: () => inputTriggers,
- defaultSink: sink,
- commandImages,
- })
- chip(shell)
- expect(shell.snapshot).toMatchObject({
- draft: '@Research ',
- occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
- })
- shell.submit('queue')
- expect(shell.snapshot.phase).toBe('submitting')
- await vi.waitFor(() => {
- expect(shell.snapshot.phase).toBe('plain')
- })
- expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
- expect(shell.snapshot).toMatchObject({
- draft: '@Research ',
- occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
- })
- expect(shell.notices.getSnapshot()).toMatchObject({
- level: 'error',
- text: 'snapshot unavailable',
- })
- shell.submit('queue')
- await vi.waitFor(() => {
- expect(shell.snapshot.draft).toBe('')
- })
- expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
- expect(shell.snapshot.occurrences).toEqual([])
- expect(serializeReference).toHaveBeenCalledTimes(2)
- })
- it('blocks submission and retains the chip when its owner cannot serialize it', async () => {
- const sink = vi.fn()
- const inputTriggers = {
- serializeReference: () => Promise.reject(new Error('reference codec unavailable')),
- track: vi.fn(),
- } as unknown as InputTriggerController
- const shell = new SessionInputShell({
- actx: {} as Context,
- inputTriggers: () => inputTriggers,
- defaultSink: sink,
- commandImages,
- })
- chip(shell)
- shell.submit()
- await vi.waitFor(() => {
- expect(shell.snapshot.phase).toBe('plain')
- })
- expect(sink).not.toHaveBeenCalled()
- expect(shell.snapshot.draft).toBe('@Research ')
- expect(shell.snapshot.occurrences).toHaveLength(1)
- expect(shell.notices.getSnapshot()).toMatchObject({
- level: 'error',
- text: 'reference codec unavailable',
- })
- })
- it('aborts Host-side preparation when the input shell is disposed', () => {
- let signal: AbortSignal | undefined
- const shell = new SessionInputShell({
- actx: {} as Context,
- defaultSink: (_text, _imageIds, _mode, received) => {
- signal = received
- return new Promise<SubmitOutcome>(() => {})
- },
- commandImages,
- })
- shell.setDraft('send this')
- shell.submit()
- expect(signal?.aborted).toBe(false)
- shell.dispose()
- expect(signal?.aborted).toBe(true)
- expect(shell.snapshot.phase).toBe('plain')
- expect(shell.snapshot.draft).toBe('send this')
- })
- it('retains a rejected default message without duplicating its prompt error notice', async () => {
- const shell = new SessionInputShell({
- actx: {} as Context,
- defaultSink: () => Promise.resolve({ kind: 'error' }),
- commandImages,
- })
- shell.setDraft('retry this')
- shell.submit()
- await vi.waitFor(() => {
- expect(shell.snapshot.phase).toBe('plain')
- })
- expect(shell.snapshot.draft).toBe('retry this')
- expect(shell.notices.getSnapshot()).toBeNull()
- })
- })
- describe('submit transaction hardening', () => {
- it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => {
- let settle!: (outcome: SubmitOutcome) => void
- const sink = vi.fn(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
- const shell = new SessionInputShell({
- actx: {} as Context,
- defaultSink: sink,
- commandImages,
- })
- expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true)
- shell.submit('queue')
- shell.submit('queue')
- expect(sink).toHaveBeenCalledTimes(1)
- settle({ kind: 'success' })
- await vi.waitFor(() => {
- expect(shell.snapshot.imageIds).toEqual([])
- })
- expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true)
- shell.submit('queue')
- expect(sink).toHaveBeenCalledTimes(2)
- })
- it('retains an image-only rejection without duplicating its prompt error notice', async () => {
- const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'error' }))
- const shell = new SessionInputShell({
- actx: {} as Context,
- defaultSink: sink,
- commandImages,
- })
- const imageId = 'img-1' as DraftAttachmentId
- shell.addImages([imageId])
- shell.submit()
- await Promise.resolve()
- await Promise.resolve()
- expect(shell.snapshot.imageIds).toEqual([imageId])
- expect(shell.notices.getSnapshot()).toBeNull()
- })
- it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => {
- const track = vi.fn()
- const shell = new SessionInputShell({
- actx: {} as Context,
- inputTriggers: () => ({ track } as unknown as InputTriggerController),
- defaultSink: vi.fn(),
- commandImages,
- })
- shell.setDraft('@sr')
- const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true)
- expect(applied).toBe(true)
- expect(shell.snapshot.draft).toBe('@src/')
- expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev)
- track.mockClear()
- shell.insertText(' plain ', { start: 0, end: 0, draftRev: shell.snapshot.draftRev })
- expect(track).not.toHaveBeenCalled()
- })
- })
|