model.client.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import { describe, expect, it, vi } from 'vitest'
  2. import {
  3. ClientWorkspaceModel, type WorkspaceRemote,
  4. } from '../src/client/index.ts'
  5. import type {
  6. WorkspaceArchiveSessionRequest,
  7. WorkspaceArchiveValue,
  8. WorkspaceCreateRequest,
  9. WorkspaceCreateValue,
  10. WorkspaceDeleteRequest,
  11. WorkspaceDeleteValue,
  12. WorkspaceFollowFrame,
  13. WorkspaceInsertBeforeRequest,
  14. WorkspaceInsertSessionBeforeRequest,
  15. WorkspaceOrderValue,
  16. WorkspaceRenameRequest,
  17. WorkspaceValue,
  18. WorkspaceId,
  19. WorkspaceView,
  20. } from '../src/types.ts'
  21. import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  22. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  23. const sid = (id: string): SessionId => id as SessionId
  24. const wid = (id: string): WorkspaceId => id as WorkspaceId
  25. function workspace(
  26. id: string,
  27. sessionIds: readonly SessionId[] = [],
  28. updatedAt = '2026-01-01T00:00:00.000Z',
  29. ): WorkspaceView {
  30. return {
  31. workspaceId: wid(id),
  32. path: `/w/${id}`,
  33. title: id,
  34. sessionIds,
  35. createdAt: '2026-01-01T00:00:00.000Z',
  36. updatedAt,
  37. }
  38. }
  39. function remoteOk<T>(value: T): RemoteResult<T> {
  40. return { ok: true, value }
  41. }
  42. function workspaceError(error: RemoteFailure): RemoteResult<never> {
  43. return { ok: false, error }
  44. }
  45. interface Deferred<T> {
  46. readonly promise: Promise<T>
  47. resolve(value: T): void
  48. reject(error: unknown): void
  49. }
  50. function deferred<T>(): Deferred<T> {
  51. let resolve!: (value: T) => void
  52. let reject!: (error: unknown) => void
  53. const promise = new Promise<T>((accept, fail) => {
  54. resolve = accept
  55. reject = fail
  56. })
  57. return { promise, reject, resolve }
  58. }
  59. class FakeWorkspaceRemote implements WorkspaceRemote {
  60. readonly calls: Array<{ readonly method: string; readonly request: unknown }> = []
  61. onCreate: (request: WorkspaceCreateRequest) => Promise<RemoteResult<WorkspaceCreateValue>> = request =>
  62. Promise.resolve(remoteOk({ workspace: workspace(request.path.split('/').pop() ?? 'workspace'), created: true }))
  63. onRename: (request: WorkspaceRenameRequest) => Promise<RemoteResult<WorkspaceValue>> = request =>
  64. Promise.resolve(remoteOk({ workspace: { ...workspace(String(request.workspaceId)), title: request.title } }))
  65. onDelete: (_request: WorkspaceDeleteRequest) => Promise<RemoteResult<WorkspaceDeleteValue>> = () =>
  66. Promise.resolve(remoteOk({ deleted: true }))
  67. onInsertBefore: (
  68. request: WorkspaceInsertBeforeRequest,
  69. ) => Promise<RemoteResult<WorkspaceOrderValue>> = request =>
  70. Promise.resolve(remoteOk({ workspaceIds: [request.workspaceId] }))
  71. onInsertSessionBefore: (
  72. request: WorkspaceInsertSessionBeforeRequest,
  73. ) => Promise<RemoteResult<WorkspaceValue>> = request => Promise.resolve(remoteOk({
  74. workspace: workspace(String(request.workspaceId), [request.sessionId]),
  75. }))
  76. onArchiveSession: (
  77. request: WorkspaceArchiveSessionRequest,
  78. ) => Promise<RemoteResult<WorkspaceArchiveValue>> = request =>
  79. Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
  80. create(request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
  81. this.record('create', request)
  82. return this.onCreate(request)
  83. }
  84. rename(request: WorkspaceRenameRequest): Promise<RemoteResult<WorkspaceValue>> {
  85. this.record('rename', request)
  86. return this.onRename(request)
  87. }
  88. delete(request: WorkspaceDeleteRequest): Promise<RemoteResult<WorkspaceDeleteValue>> {
  89. this.record('delete', request)
  90. return this.onDelete(request)
  91. }
  92. insertBefore(request: WorkspaceInsertBeforeRequest): Promise<RemoteResult<WorkspaceOrderValue>> {
  93. this.record('insertBefore', request)
  94. return this.onInsertBefore(request)
  95. }
  96. insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise<RemoteResult<WorkspaceValue>> {
  97. this.record('insertSessionBefore', request)
  98. return this.onInsertSessionBefore(request)
  99. }
  100. archiveSession(request: WorkspaceArchiveSessionRequest): Promise<RemoteResult<WorkspaceArchiveValue>> {
  101. this.record('archiveSession', request)
  102. return this.onArchiveSession(request)
  103. }
  104. async *follow(_signal?: AbortSignal): AsyncGenerator<WorkspaceFollowFrame> {}
  105. private record(method: string, request: unknown): void {
  106. this.calls.push({ method, request })
  107. }
  108. }
  109. function modelFor(remote = new FakeWorkspaceRemote()): ClientWorkspaceModel {
  110. return new ClientWorkspaceModel(remote)
  111. }
  112. function baseline(
  113. model: ClientWorkspaceModel,
  114. items: readonly WorkspaceView[] = [],
  115. archivedSessionIds: readonly SessionId[] = [],
  116. ): void {
  117. model.replaceBaseline({ items, archivedSessionIds })
  118. }
  119. describe('ClientWorkspaceModel', () => {
  120. it('replaces reconnect state and applies ordered increments', () => {
  121. const model = modelFor()
  122. expect(model.getSnapshot()).toMatchObject({ phase: 'pending', state: 'loading' })
  123. baseline(model, [workspace('old'), workspace('kept')])
  124. model.upsertView(workspace('new'))
  125. model.replaceOrder([wid('kept'), wid('new'), wid('old')])
  126. model.replaceArchived([sid('hidden')])
  127. model.removeView(wid('old'))
  128. expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', archivedSessionIds: ['hidden'] })
  129. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept', 'new'])
  130. baseline(model, [workspace('fresh')])
  131. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['fresh'])
  132. expect(model.getSnapshot().archivedSessionIds).toEqual([])
  133. })
  134. it('keeps the last baseline during retry and exposes a terminal stream failure', () => {
  135. const model = modelFor()
  136. baseline(model, [workspace('visible')])
  137. model.handleCarrierFailure()
  138. expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null })
  139. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible'])
  140. model.handleStreamFailure(new RemoteError('gateway/internal', 'wire down', {}))
  141. expect(model.getSnapshot()).toMatchObject({
  142. phase: 'ready', state: 'error', error: { code: 'gateway/internal', message: 'wire down' },
  143. })
  144. // An unmarked value never crosses the stream boundary: it is a local fault.
  145. expect(() => { model.handleStreamFailure('plain failure') }).toThrow()
  146. baseline(model, [workspace('restored')])
  147. expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null })
  148. })
  149. it('creates by path and prepends the returned row', async () => {
  150. const remote = new FakeWorkspaceRemote()
  151. const model = modelFor(remote)
  152. remote.onCreate = request => Promise.resolve(remoteOk({
  153. workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
  154. created: request.path === '/w/created',
  155. }))
  156. await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
  157. expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } })
  158. expect(model.getSnapshot().items[0]?.workspaceId).toBe('created')
  159. })
  160. it('lets newer stream order outrank unary echoes and rolls failures back', async () => {
  161. const remote = new FakeWorkspaceRemote()
  162. const model = modelFor(remote)
  163. baseline(model, [workspace('one'), workspace('two'), workspace('three')])
  164. const gate = deferred<RemoteResult<WorkspaceOrderValue>>()
  165. remote.onInsertBefore = () => gate.promise
  166. const pending = model.insertBefore(wid('three'), wid('one'))
  167. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
  168. model.replaceOrder([wid('one'), wid('three'), wid('two')])
  169. gate.resolve(remoteOk({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
  170. await pending
  171. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
  172. remote.onInsertBefore = () => Promise.resolve(workspaceError(
  173. new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('three') }),
  174. ))
  175. const rejected = model.insertBefore(wid('three'))
  176. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
  177. await expect(rejected).resolves.toMatchObject({ ok: false })
  178. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
  179. })
  180. it('keeps a newer optimistic reorder when an older refused call settles', async () => {
  181. const remote = new FakeWorkspaceRemote()
  182. const model = modelFor(remote)
  183. baseline(model, [workspace('one'), workspace('two'), workspace('three')])
  184. const firstGate = deferred<RemoteResult<WorkspaceOrderValue>>()
  185. const secondGate = deferred<RemoteResult<WorkspaceOrderValue>>()
  186. let request = 0
  187. remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
  188. const first = model.insertBefore(wid('three'), wid('one'))
  189. const second = model.insertBefore(wid('two'), wid('three'))
  190. firstGate.resolve(workspaceError(
  191. new RemoteError('workspace/not-found', 'first refused', { workspaceId: wid('three') }),
  192. ))
  193. await expect(first).resolves.toMatchObject({ ok: false })
  194. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
  195. secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] }))
  196. await expect(second).resolves.toMatchObject({ ok: true })
  197. })
  198. it('rolls overlapping rejected reorders back to the last Host order', async () => {
  199. const remote = new FakeWorkspaceRemote()
  200. const model = modelFor(remote)
  201. baseline(model, [workspace('one'), workspace('two'), workspace('three')])
  202. const firstGate = deferred<RemoteResult<WorkspaceOrderValue>>()
  203. const secondGate = deferred<RemoteResult<WorkspaceOrderValue>>()
  204. let request = 0
  205. remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
  206. const first = model.insertBefore(wid('three'), wid('one'))
  207. const second = model.insertBefore(wid('two'), wid('three'))
  208. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
  209. firstGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'first rejected', { workspaceId: wid('three') })))
  210. await expect(first).resolves.toMatchObject({ ok: false })
  211. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
  212. secondGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'second rejected', { workspaceId: wid('two') })))
  213. await expect(second).resolves.toMatchObject({ ok: false })
  214. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
  215. })
  216. it('retains removal tombstones across later baselines', () => {
  217. const model = modelFor()
  218. baseline(model, [workspace('gone'), workspace('kept')])
  219. model.removeView(wid('gone'))
  220. model.removeView(wid('gone'))
  221. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
  222. baseline(model, [workspace('gone')])
  223. expect(model.getSnapshot().items).toEqual([])
  224. })
  225. it('does not let delayed unary data resurrect a removed Workspace', async () => {
  226. const remote = new FakeWorkspaceRemote()
  227. const model = modelFor(remote)
  228. baseline(model, [workspace('gone')])
  229. const gate = deferred<RemoteResult<WorkspaceValue>>()
  230. remote.onRename = () => gate.promise
  231. const rename = model.rename(wid('gone'), 'late')
  232. model.removeView(wid('gone'))
  233. gate.resolve(remoteOk({ workspace: { ...workspace('gone'), title: 'late' } }))
  234. await expect(rename).resolves.toMatchObject({ ok: true })
  235. expect(model.getSnapshot().items).toEqual([])
  236. })
  237. it('applies Workspace mutation echoes and leaves failed results unchanged', async () => {
  238. const remote = new FakeWorkspaceRemote()
  239. const model = modelFor(remote)
  240. baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')])
  241. remote.onRename = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') })))
  242. await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false })
  243. expect(model.getSnapshot().items[0]?.title).toBe('one')
  244. remote.onDelete = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') })))
  245. await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false })
  246. expect(model.getSnapshot().items).toHaveLength(1)
  247. remote.onInsertSessionBefore = request => Promise.resolve(remoteOk({
  248. workspace: workspace('one', [request.sessionId, sid('first')], '2026-02-01T00:00:00.000Z'),
  249. }))
  250. await expect(model.insertSessionBefore(wid('one'), sid('second'), sid('first')))
  251. .resolves.toMatchObject({ ok: true })
  252. expect(remote.calls).toContainEqual({
  253. method: 'insertSessionBefore',
  254. request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' },
  255. })
  256. remote.onInsertSessionBefore = () => Promise.resolve(workspaceError(
  257. new RemoteError('workspace/move-invalid', 'invalid move', { workspaceId: wid('one'), sessionId: sid('second') }),
  258. ))
  259. await expect(model.insertSessionBefore(wid('one'), sid('second')))
  260. .resolves.toMatchObject({ ok: false })
  261. expect(remote.calls).toContainEqual({
  262. method: 'insertSessionBefore',
  263. request: { workspaceId: 'one', sessionId: 'second' },
  264. })
  265. remote.onArchiveSession = () => Promise.resolve(workspaceError(
  266. new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
  267. ))
  268. await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false })
  269. expect(model.getSnapshot().archivedSessionIds).toEqual(['archived'])
  270. remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
  271. await expect(model.archiveSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
  272. expect(model.getSnapshot().archivedSessionIds).toEqual(['fresh'])
  273. })
  274. it('keeps the newest row and places Workspaces missing from partial orders last', async () => {
  275. const model = modelFor()
  276. baseline(model, [
  277. workspace('one', [], '2026-02-01T00:00:00.000Z'),
  278. workspace('two'),
  279. ])
  280. model.upsertView(workspace('one', [], '2025-12-01T00:00:00.000Z'))
  281. expect(model.getSnapshot().items[0]?.updatedAt).toBe('2026-02-01T00:00:00.000Z')
  282. model.upsertView(workspace('one', [sid('new')], '2026-03-01T00:00:00.000Z'))
  283. expect(model.getSnapshot().items[0]?.sessionIds).toEqual(['new'])
  284. model.replaceOrder([wid('one')])
  285. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
  286. model.replaceOrder([wid('two')])
  287. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
  288. model.replaceOrder([wid('one')])
  289. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
  290. await expect(model.insertBefore(wid('one'), wid('one'))).resolves.toMatchObject({ ok: true })
  291. expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
  292. })
  293. it('notifies subscribers and cancels a queued notification after an immediate delete echo', async () => {
  294. const remote = new FakeWorkspaceRemote()
  295. const model = modelFor(remote)
  296. baseline(model, [workspace('gone')])
  297. await Promise.resolve()
  298. const listener = vi.fn()
  299. const unsubscribe = model.subscribe(listener)
  300. const deletion = model.delete(wid('gone'))
  301. model.removeView(wid('gone'))
  302. await expect(deletion).resolves.toMatchObject({ ok: true })
  303. expect(listener).toHaveBeenCalledOnce()
  304. await Promise.resolve()
  305. expect(listener).toHaveBeenCalledOnce()
  306. unsubscribe()
  307. model.handleCarrierFailure()
  308. await Promise.resolve()
  309. expect(listener).toHaveBeenCalledOnce()
  310. })
  311. it('removes from a unary delete echo before the operation resolves', async () => {
  312. const remote = new FakeWorkspaceRemote()
  313. const model = modelFor(remote)
  314. baseline(model, [workspace('gone')])
  315. await expect(model.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
  316. expect(remote.calls).toContainEqual({ method: 'delete', request: { workspaceId: 'gone' } })
  317. expect(model.getSnapshot().items).toEqual([])
  318. model.removeView(wid('gone'))
  319. expect(model.getSnapshot().items).toEqual([])
  320. })
  321. })