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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  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 '../src/client/contract/input.ts'
  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 commandAttachments = {
  14. serialize: () => Promise.resolve([]),
  15. release: () => {},
  16. unsupportedNotice: (token: string) => `${token.trim()} attachments-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. commandAttachments,
  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. // InputState.draft IS the clipboard projection now (chips expand to their
  54. // canonical text); the display label lives in the chip's decorator DOM.
  55. expect(first.snapshot.draft).toBe(`${spacedMention} `)
  56. expect(mirror).toHaveBeenLastCalledWith(`${spacedMention} `)
  57. const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
  58. const restored = new SessionInputShell({
  59. actx: {} as Context,
  60. defaultSink: sink,
  61. commandAttachments,
  62. })
  63. restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string)
  64. restored.submit()
  65. await vi.waitFor(() => {
  66. expect(sink).toHaveBeenCalledWith(spacedMention, [], 'queue', expect.any(AbortSignal))
  67. })
  68. })
  69. it('retains the chip on Host failure and clears it only after a later accepted retry', async () => {
  70. const serializeReference = vi.fn(() => Promise.resolve(mention))
  71. const sink = vi.fn<(
  72. _text: string,
  73. _imageIds: readonly DraftAttachmentId[],
  74. _mode: 'queue' | 'steer',
  75. _signal: AbortSignal,
  76. ) => Promise<SubmitOutcome>>()
  77. .mockResolvedValueOnce({ kind: 'error', text: 'snapshot unavailable' })
  78. .mockResolvedValueOnce({ kind: 'success' })
  79. const inputTriggers = {
  80. serializeReference,
  81. track: vi.fn(),
  82. lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
  83. } as unknown as InputTriggerController
  84. const shell = new SessionInputShell({
  85. actx: {} as Context,
  86. inputTriggers: () => inputTriggers,
  87. defaultSink: sink,
  88. commandAttachments,
  89. })
  90. chip(shell)
  91. expect(shell.snapshot).toMatchObject({
  92. draft: `${mention} `,
  93. occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: mention.length }],
  94. })
  95. shell.submit('queue')
  96. // Optimistic commit: the composer clears at enter and stays unlocked
  97. // while the detached flight runs.
  98. expect(shell.snapshot.phase).toBe('plain')
  99. expect(shell.snapshot.draft).toBe('')
  100. await vi.waitFor(() => {
  101. expect(shell.snapshot.draft).toBe(`${mention} `)
  102. })
  103. expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
  104. expect(shell.snapshot).toMatchObject({
  105. draft: `${mention} `,
  106. occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: mention.length }],
  107. })
  108. expect(shell.notices.getSnapshot()).toMatchObject({
  109. level: 'error',
  110. text: 'snapshot unavailable',
  111. })
  112. shell.submit('queue')
  113. expect(shell.snapshot.draft).toBe('')
  114. await vi.waitFor(() => {
  115. expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
  116. })
  117. expect(shell.snapshot.occurrences).toEqual([])
  118. expect(serializeReference).toHaveBeenCalledTimes(2)
  119. })
  120. it('blocks submission and retains the chip when its owner cannot serialize it', async () => {
  121. const sink = vi.fn()
  122. const inputTriggers = {
  123. serializeReference: () => Promise.reject(new Error('reference codec unavailable')),
  124. track: vi.fn(),
  125. lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
  126. } as unknown as InputTriggerController
  127. const shell = new SessionInputShell({
  128. actx: {} as Context,
  129. inputTriggers: () => inputTriggers,
  130. defaultSink: sink,
  131. commandAttachments,
  132. })
  133. chip(shell)
  134. shell.submit()
  135. // The serializer rejection restores the optimistic commit with its chip.
  136. await vi.waitFor(() => {
  137. expect(shell.snapshot.draft).toBe(`${mention} `)
  138. })
  139. expect(sink).not.toHaveBeenCalled()
  140. expect(shell.snapshot.occurrences).toHaveLength(1)
  141. expect(shell.notices.getSnapshot()).toMatchObject({
  142. level: 'error',
  143. text: 'reference codec unavailable',
  144. })
  145. })
  146. it('aborts Host-side preparation when the input shell is disposed', () => {
  147. let signal: AbortSignal | undefined
  148. const shell = new SessionInputShell({
  149. actx: {} as Context,
  150. defaultSink: (_text, _imageIds, _mode, received) => {
  151. signal = received
  152. return new Promise<SubmitOutcome>(() => {})
  153. },
  154. commandAttachments,
  155. })
  156. shell.setDraft('send this')
  157. shell.submit()
  158. expect(signal?.aborted).toBe(false)
  159. shell.dispose()
  160. expect(signal?.aborted).toBe(true)
  161. expect(shell.snapshot.phase).toBe('plain')
  162. // The optimistic commit stands: disposal drops the settlement, so the
  163. // sent draft is not restored into the dying composer.
  164. expect(shell.snapshot.draft).toBe('')
  165. })
  166. it('retains a rejected default message without duplicating its prompt error notice', async () => {
  167. const shell = new SessionInputShell({
  168. actx: {} as Context,
  169. defaultSink: () => Promise.resolve({ kind: 'error' }),
  170. commandAttachments,
  171. })
  172. shell.setDraft('retry this')
  173. shell.submit()
  174. await vi.waitFor(() => {
  175. expect(shell.snapshot.phase).toBe('plain')
  176. })
  177. expect(shell.snapshot.draft).toBe('retry this')
  178. expect(shell.notices.getSnapshot()).toBeNull()
  179. })
  180. it('restores concurrent failed messages in submission order', async () => {
  181. const settlements: Array<(outcome: SubmitOutcome) => void> = []
  182. const shell = new SessionInputShell({
  183. actx: {} as Context,
  184. defaultSink: () => new Promise<SubmitOutcome>((resolve) => { settlements.push(resolve) }),
  185. commandAttachments,
  186. })
  187. shell.setDraft('first')
  188. shell.submit()
  189. shell.setDraft('second')
  190. shell.submit()
  191. expect(shell.snapshot.draft).toBe('')
  192. settlements[0]?.({ kind: 'error' })
  193. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first') })
  194. settlements[1]?.({ kind: 'error' })
  195. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first\n\nsecond') })
  196. })
  197. })
  198. describe('submit transaction hardening', () => {
  199. it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => {
  200. let settle!: (outcome: SubmitOutcome) => void
  201. const sink = vi.fn(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
  202. const shell = new SessionInputShell({
  203. actx: {} as Context,
  204. defaultSink: sink,
  205. commandAttachments,
  206. })
  207. expect(shell.addAttachments(['img-1' as DraftAttachmentId])).toBe(true)
  208. shell.submit('queue')
  209. shell.submit('queue')
  210. expect(sink).toHaveBeenCalledTimes(1)
  211. settle({ kind: 'success' })
  212. await vi.waitFor(() => {
  213. expect(shell.snapshot.attachmentIds).toEqual([])
  214. })
  215. expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true)
  216. shell.submit('queue')
  217. expect(sink).toHaveBeenCalledTimes(2)
  218. })
  219. it('retains an image-only rejection without duplicating its prompt error notice', async () => {
  220. const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'error' }))
  221. const shell = new SessionInputShell({
  222. actx: {} as Context,
  223. defaultSink: sink,
  224. commandAttachments,
  225. })
  226. const imageId = 'img-1' as DraftAttachmentId
  227. shell.addAttachments([imageId])
  228. shell.submit()
  229. await Promise.resolve()
  230. await Promise.resolve()
  231. expect(shell.snapshot.attachmentIds).toEqual([imageId])
  232. expect(shell.notices.getSnapshot()).toBeNull()
  233. })
  234. it('aborts an unsettled image-only send and returns its image id at disposal', () => {
  235. let signal: AbortSignal | undefined
  236. const imageId = 'img-flight' as DraftAttachmentId
  237. const shell = new SessionInputShell({
  238. actx: {} as Context,
  239. defaultSink: (_text, _ids, _mode, received) => {
  240. signal = received
  241. return new Promise<SubmitOutcome>(() => {})
  242. },
  243. commandAttachments,
  244. })
  245. shell.addAttachments([imageId])
  246. shell.submit()
  247. expect(signal?.aborted).toBe(false)
  248. expect(shell.dispose()).toEqual([imageId])
  249. expect(signal?.aborted).toBe(true)
  250. })
  251. it('re-tracks at the caret when an insert-text splice lands (directory descent reopens the menu)', () => {
  252. const track = vi.fn()
  253. const lexicon = { getSnapshot: () => new Map(), subscribe: () => () => {} }
  254. const shell = new SessionInputShell({
  255. actx: {} as Context,
  256. inputTriggers: () => ({ track, lexicon } as unknown as InputTriggerController),
  257. defaultSink: vi.fn(),
  258. commandAttachments,
  259. })
  260. shell.setDraft('@sr')
  261. const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true)
  262. expect(applied).toBe(true)
  263. expect(shell.snapshot.draft).toBe('@src/')
  264. // Every editor commit re-tracks at the settled caret (the continue flag
  265. // is a contract passenger now): a trailing '/' keeps the menu open.
  266. expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev)
  267. })
  268. })