input-reference-submit.client.spec.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. /**
  2. * Reference-submit transaction coverage: chips serialize through their
  3. * owner, stay resident through Host rejection, and clear only after an
  4. * accepted prompt.
  5. */
  6. import { describe, expect, it, vi } from 'vitest'
  7. import type { Context } from '@deepseek-ai/cordis'
  8. import type { InputTriggerController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  9. import { SessionInputShell } from '../src/client/input/facade.ts'
  10. import type { DraftAttachmentId } from '../src/client/contract/input.ts'
  11. const mention = '@[Research](dsh-session:InNvdXJjZSI)'
  12. const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)'
  13. const commandImages = {
  14. serialize: () => Promise.resolve([]),
  15. release: () => {},
  16. unsupportedNotice: (token: string) => `${token.trim()} images-unsupported`,
  17. }
  18. function chip(shell: SessionInputShell): void {
  19. shell.setDraft('@res')
  20. const accepted = shell.insertReference({
  21. source: 'reference',
  22. ref: mention,
  23. label: 'Research',
  24. clipboardText: mention,
  25. }, {
  26. start: 0,
  27. end: 4,
  28. draftRev: shell.snapshot.draftRev,
  29. })
  30. expect(accepted).toBe(true)
  31. }
  32. describe('reference submission', () => {
  33. it('mirrors canonical reference text so a persisted draft remains resolvable after remount', async () => {
  34. const mirror = vi.fn()
  35. const first = new SessionInputShell({
  36. actx: {} as Context,
  37. defaultSink: vi.fn(),
  38. commandImages,
  39. })
  40. first.bindMirror(mirror)
  41. first.setDraft('@res')
  42. expect(first.insertReference({
  43. source: 'reference',
  44. ref: spacedMention,
  45. label: 'Research notes',
  46. appearance: 'session',
  47. clipboardText: spacedMention,
  48. }, {
  49. start: 0,
  50. end: 4,
  51. draftRev: first.snapshot.draftRev,
  52. })).toBe(true)
  53. expect(first.snapshot.draft).toBe('@Research notes ')
  54. expect(mirror).toHaveBeenLastCalledWith(`${spacedMention} `)
  55. const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
  56. const restored = new SessionInputShell({
  57. actx: {} as Context,
  58. defaultSink: sink,
  59. commandImages,
  60. })
  61. restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string)
  62. restored.submit()
  63. await vi.waitFor(() => {
  64. expect(sink).toHaveBeenCalledWith(spacedMention, [], 'queue', expect.any(AbortSignal))
  65. })
  66. })
  67. it('retains the chip on Host failure and clears it only after a later accepted retry', async () => {
  68. const serializeReference = vi.fn(() => Promise.resolve(mention))
  69. const sink = vi.fn<(
  70. _text: string,
  71. _imageIds: readonly DraftAttachmentId[],
  72. _mode: 'queue' | 'steer',
  73. _signal: AbortSignal,
  74. ) => Promise<SubmitOutcome>>()
  75. .mockResolvedValueOnce({ kind: 'error', text: 'snapshot unavailable' })
  76. .mockResolvedValueOnce({ kind: 'success' })
  77. const inputTriggers = {
  78. serializeReference,
  79. track: vi.fn(),
  80. } as unknown as InputTriggerController
  81. const shell = new SessionInputShell({
  82. actx: {} as Context,
  83. inputTriggers: () => inputTriggers,
  84. defaultSink: sink,
  85. commandImages,
  86. })
  87. chip(shell)
  88. expect(shell.snapshot).toMatchObject({
  89. draft: '@Research ',
  90. occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
  91. })
  92. shell.submit('queue')
  93. expect(shell.snapshot.phase).toBe('submitting')
  94. await vi.waitFor(() => {
  95. expect(shell.snapshot.phase).toBe('plain')
  96. })
  97. expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
  98. expect(shell.snapshot).toMatchObject({
  99. draft: '@Research ',
  100. occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
  101. })
  102. expect(shell.notices.getSnapshot()).toMatchObject({
  103. level: 'error',
  104. text: 'snapshot unavailable',
  105. })
  106. shell.submit('queue')
  107. await vi.waitFor(() => {
  108. expect(shell.snapshot.draft).toBe('')
  109. })
  110. expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
  111. expect(shell.snapshot.occurrences).toEqual([])
  112. expect(serializeReference).toHaveBeenCalledTimes(2)
  113. })
  114. it('blocks submission and retains the chip when its owner cannot serialize it', async () => {
  115. const sink = vi.fn()
  116. const inputTriggers = {
  117. serializeReference: () => Promise.reject(new Error('reference codec unavailable')),
  118. track: vi.fn(),
  119. } as unknown as InputTriggerController
  120. const shell = new SessionInputShell({
  121. actx: {} as Context,
  122. inputTriggers: () => inputTriggers,
  123. defaultSink: sink,
  124. commandImages,
  125. })
  126. chip(shell)
  127. shell.submit()
  128. await vi.waitFor(() => {
  129. expect(shell.snapshot.phase).toBe('plain')
  130. })
  131. expect(sink).not.toHaveBeenCalled()
  132. expect(shell.snapshot.draft).toBe('@Research ')
  133. expect(shell.snapshot.occurrences).toHaveLength(1)
  134. expect(shell.notices.getSnapshot()).toMatchObject({
  135. level: 'error',
  136. text: 'reference codec unavailable',
  137. })
  138. })
  139. it('aborts Host-side preparation when the input shell is disposed', () => {
  140. let signal: AbortSignal | undefined
  141. const shell = new SessionInputShell({
  142. actx: {} as Context,
  143. defaultSink: (_text, _imageIds, _mode, received) => {
  144. signal = received
  145. return new Promise<SubmitOutcome>(() => {})
  146. },
  147. commandImages,
  148. })
  149. shell.setDraft('send this')
  150. shell.submit()
  151. expect(signal?.aborted).toBe(false)
  152. shell.dispose()
  153. expect(signal?.aborted).toBe(true)
  154. expect(shell.snapshot.phase).toBe('plain')
  155. expect(shell.snapshot.draft).toBe('send this')
  156. })
  157. it('retains a rejected default message without duplicating its prompt error notice', async () => {
  158. const shell = new SessionInputShell({
  159. actx: {} as Context,
  160. defaultSink: () => Promise.resolve({ kind: 'error' }),
  161. commandImages,
  162. })
  163. shell.setDraft('retry this')
  164. shell.submit()
  165. await vi.waitFor(() => {
  166. expect(shell.snapshot.phase).toBe('plain')
  167. })
  168. expect(shell.snapshot.draft).toBe('retry this')
  169. expect(shell.notices.getSnapshot()).toBeNull()
  170. })
  171. })
  172. describe('submit transaction hardening', () => {
  173. it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => {
  174. let settle!: (outcome: SubmitOutcome) => void
  175. const sink = vi.fn(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
  176. const shell = new SessionInputShell({
  177. actx: {} as Context,
  178. defaultSink: sink,
  179. commandImages,
  180. })
  181. expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true)
  182. shell.submit('queue')
  183. shell.submit('queue')
  184. expect(sink).toHaveBeenCalledTimes(1)
  185. settle({ kind: 'success' })
  186. await vi.waitFor(() => {
  187. expect(shell.snapshot.imageIds).toEqual([])
  188. })
  189. expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true)
  190. shell.submit('queue')
  191. expect(sink).toHaveBeenCalledTimes(2)
  192. })
  193. it('retains an image-only rejection without duplicating its prompt error notice', async () => {
  194. const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'error' }))
  195. const shell = new SessionInputShell({
  196. actx: {} as Context,
  197. defaultSink: sink,
  198. commandImages,
  199. })
  200. const imageId = 'img-1' as DraftAttachmentId
  201. shell.addImages([imageId])
  202. shell.submit()
  203. await Promise.resolve()
  204. await Promise.resolve()
  205. expect(shell.snapshot.imageIds).toEqual([imageId])
  206. expect(shell.notices.getSnapshot()).toBeNull()
  207. })
  208. it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => {
  209. const track = vi.fn()
  210. const shell = new SessionInputShell({
  211. actx: {} as Context,
  212. inputTriggers: () => ({ track } as unknown as InputTriggerController),
  213. defaultSink: vi.fn(),
  214. commandImages,
  215. })
  216. shell.setDraft('@sr')
  217. const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true)
  218. expect(applied).toBe(true)
  219. expect(shell.snapshot.draft).toBe('@src/')
  220. expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev)
  221. track.mockClear()
  222. shell.insertText(' plain ', { start: 0, end: 0, draftRev: shell.snapshot.draftRev })
  223. expect(track).not.toHaveBeenCalled()
  224. })
  225. })