model.client.spec.ts 17 KB

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