api-proxy-workspace.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { Context } from 'cordis'
  6. import AgentRegistry from '@deepseek-ai/dsh-agent'
  7. import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
  8. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  9. import type { Session } from '@deepseek-ai/dsh-session'
  10. import Storage from '@deepseek-ai/dsh-storage'
  11. import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
  12. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  13. import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
  14. import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
  15. import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
  16. import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
  17. import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  18. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  19. import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
  20. import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
  21. let nextRpc = 1
  22. function request<P>(payload: P): RpcRequest<P> {
  23. return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
  24. }
  25. function expectOk<T>(response: RpcResponse<T>): T {
  26. expect(response.result.ok).toBe(true)
  27. if (!response.result.ok) throw new Error('unreachable')
  28. return response.result.value
  29. }
  30. async function nextHostFrame(
  31. stream: AsyncIterator<RpcRequest<HostFrame>>,
  32. ): Promise<RpcRequest<HostFrame>> {
  33. const next = await stream.next()
  34. if (next.done === true) throw new Error('Host stream ended before the expected increment')
  35. return next.value
  36. }
  37. function stubAgent(session: Session): Agent {
  38. return {
  39. id: session.id,
  40. options: {},
  41. session,
  42. status: 'idle',
  43. acceptsNextStep: false,
  44. ctx: new Context(),
  45. followup: () => {},
  46. steer: () => {},
  47. inject: () => {},
  48. send: () => {},
  49. updateInbox: () => 'not-found',
  50. cancel() {},
  51. whenIdle: () => Promise.resolve(),
  52. }
  53. }
  54. /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
  55. async function harness(
  56. workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
  57. picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
  58. extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
  59. ) {
  60. const ctx = new Context()
  61. await ctx.plugin(SessionStore)
  62. await ctx.plugin(AgentRegistry)
  63. await ctx.plugin(UserInteractionService)
  64. await ctx.plugin(Storage)
  65. ctx.storage.backend.register('memory', new MemoryStorageBackend())
  66. const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
  67. ctx.storage.mount('domain', storageDomain)
  68. ctx.provide('storageDomain', storageDomain)
  69. ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
  70. await ctx.plugin(WorkspaceRegistry)
  71. const factory: AgentFactory = {
  72. async createAgent(_ownerCtx, options) {
  73. const session = ctx.sessions.create(
  74. options.sessionId,
  75. options.meta === undefined ? {} : { meta: options.meta },
  76. )
  77. const agent = stubAgent(session)
  78. const unregister = ctx.agents.register(agent)
  79. return {
  80. agent,
  81. dispose: () => {
  82. unregister()
  83. return Promise.resolve()
  84. },
  85. }
  86. },
  87. async resume() {
  88. throw new Error('test harness has no persisted sessions')
  89. },
  90. }
  91. ctx.agents.setFactory(factory)
  92. // Structural picker fake: the gateway only reads capability(); a stable
  93. // object per harness mirrors the seam's stability contract.
  94. ctx.provide('directoryPicker', { capability: () => picker } as never)
  95. const api = createApiProxy(ctx, {
  96. provider: 'test',
  97. model: 'test-model',
  98. cwd: workspaceRoot,
  99. workspaceRoot,
  100. ...extras.openPath === undefined ? {} : { openPath: extras.openPath },
  101. })
  102. return { api, ctx, storageDomain, workspaceRoot }
  103. }
  104. describe('host.pickDirectory', () => {
  105. it('returns a selected path or explicit cancellation from the native capability', async () => {
  106. const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' })
  107. expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
  108. .toEqual({ ok: true, value: { path: '/tmp/project' } })
  109. const cancelled = await harness(undefined, { kind: 'native', pick: async () => null })
  110. expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
  111. .toEqual({ ok: true, value: { path: null } })
  112. })
  113. it('propagates abort into the native capability as a cancelled RPC error', async () => {
  114. const { api } = await harness(undefined, {
  115. kind: 'native',
  116. pick: signal => new Promise((_resolve, reject) => {
  117. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  118. }),
  119. })
  120. const abort = new AbortController()
  121. const pending = api.host.pickDirectory(request({}), abort.signal)
  122. abort.abort()
  123. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  124. })
  125. it('folds a non-abort native-chooser failure into an internal error', async () => {
  126. const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
  127. const response = await api.host.pickDirectory(request({}), new AbortController().signal)
  128. expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
  129. })
  130. it('refuses the native RPC under a browse composition', async () => {
  131. const { api } = await harness(undefined, BROWSE_STUB)
  132. const response = await api.host.pickDirectory(request({}), new AbortController().signal)
  133. expect(response.result).toMatchObject({
  134. ok: false,
  135. error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } },
  136. })
  137. })
  138. })
  139. /** Canned browse capability: one listing, one created path, typed failures on demand. */
  140. const BROWSE_STUB: DirectoryPickerCapability = {
  141. kind: 'browse',
  142. list: async (path) => {
  143. if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
  144. const target = path ?? '/home/user'
  145. return {
  146. path: target,
  147. home: '/home/user',
  148. crumbs: [{ name: '/', path: '/', hidden: false }],
  149. entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
  150. truncated: false,
  151. }
  152. },
  153. createDirectory: async (path, name) => {
  154. if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
  155. if (name === 'unwritable') throw new Error('disk detached')
  156. return `${path}/${name}`
  157. },
  158. }
  159. describe('host.listDirectory / host.createDirectory', () => {
  160. it('serves listings and creation through the browse capability, defaulting to home', async () => {
  161. const { api } = await harness(undefined, BROWSE_STUB)
  162. const home = await api.host.listDirectory(request({}), new AbortController().signal)
  163. expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
  164. const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal)
  165. expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
  166. const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
  167. expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
  168. })
  169. it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => {
  170. const { api } = await harness(undefined, BROWSE_STUB)
  171. expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({
  172. ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } },
  173. })
  174. expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({
  175. ok: false, error: { code: 'directory-exists' },
  176. })
  177. expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({
  178. ok: false, error: { code: 'internal' },
  179. })
  180. })
  181. it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => {
  182. const { api } = await harness(undefined, {
  183. kind: 'browse',
  184. list: (_path, signal) => new Promise((_resolve, reject) => {
  185. signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
  186. }),
  187. createDirectory: async () => '/never',
  188. })
  189. const abort = new AbortController()
  190. const pending = api.host.listDirectory(request({}), abort.signal)
  191. abort.abort()
  192. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  193. })
  194. it('refuses the browse RPCs under a native composition', async () => {
  195. const { api } = await harness()
  196. expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({
  197. ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
  198. })
  199. expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
  200. ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
  201. })
  202. })
  203. })
  204. describe('host.openPath', () => {
  205. it('opens through the injected native boundary', async () => {
  206. const opened: string[] = []
  207. const { api } = await harness(undefined, undefined, {
  208. openPath: async (path) => { opened.push(path) },
  209. })
  210. expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
  211. .toEqual({ ok: true, value: { opened: true } })
  212. expect(opened).toEqual(['/tmp/a.txt'])
  213. })
  214. it('propagates abort into the native boundary as a cancelled RPC error', async () => {
  215. const { api } = await harness(undefined, undefined, {
  216. openPath: (_path, signal) => new Promise((_resolve, reject) => {
  217. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  218. }),
  219. })
  220. const abort = new AbortController()
  221. const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
  222. abort.abort()
  223. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  224. })
  225. })
  226. describe('workspace.create', () => {
  227. it('serializes concurrent names and rejects the duplicate', async () => {
  228. const { api, workspaceRoot } = await harness()
  229. const responses = await Promise.all([
  230. api.workspace.create(request({ name: 'alpha' })),
  231. api.workspace.create(request({ name: 'alpha' })),
  232. ])
  233. const created = responses.find(response => response.result.ok)
  234. const duplicate = responses.find(response => !response.result.ok)
  235. expect(created).toBeDefined()
  236. expect(expectOk(created!)).toMatchObject({
  237. created: true,
  238. workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
  239. })
  240. expect(duplicate?.result).toMatchObject({
  241. ok: false,
  242. error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
  243. })
  244. expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
  245. })
  246. it('adopts only existing directories and rejects unsafe names', async () => {
  247. const { api, workspaceRoot } = await harness()
  248. const existing = join(workspaceRoot, 'existing')
  249. mkdirSync(existing)
  250. const first = expectOk(await api.workspace.create(request({ path: existing })))
  251. const repeated = expectOk(await api.workspace.create(request({ path: existing })))
  252. expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
  253. expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
  254. expectOk(await api.workspace.rename(request({
  255. workspaceId: first.workspace.workspaceId,
  256. title: 'renamed-existing',
  257. })))
  258. const reopened = expectOk(await api.workspace.create(request({ path: existing })))
  259. expect(reopened.workspace.title).toBe('renamed-existing')
  260. const missing = join(workspaceRoot, 'missing')
  261. const missingResult = await api.workspace.create(request({ path: missing }))
  262. expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
  263. expect(existsSync(missing)).toBe(false)
  264. for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
  265. const invalid = await api.workspace.create(request({ name }))
  266. expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
  267. }
  268. })
  269. it('rejects different paths that derive the same Workspace title', async () => {
  270. const { api, workspaceRoot } = await harness()
  271. const first = join(workspaceRoot, 'one', 'project')
  272. const second = join(workspaceRoot, 'two', 'project')
  273. mkdirSync(first, { recursive: true })
  274. mkdirSync(second, { recursive: true })
  275. expectOk(await api.workspace.create(request({ path: first })))
  276. const conflict = await api.workspace.create(request({ path: second }))
  277. expect(conflict.result).toMatchObject({
  278. ok: false,
  279. error: { code: 'workspace-name-conflict', details: { name: 'project' } },
  280. })
  281. })
  282. })
  283. describe('session creation and Workspace membership', () => {
  284. it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
  285. const { api, ctx } = await harness()
  286. const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  287. const sessionId = SessionId('session-workspace-preallocated')
  288. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  289. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  290. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  291. expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1)
  292. const ungrouped = SessionId('session-cwd-only')
  293. expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped })))
  294. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  295. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped)
  296. const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId }))
  297. expect(conflict.result).toMatchObject({
  298. ok: false,
  299. error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } },
  300. })
  301. const missing = await api.sessions.create(request({
  302. workspaceId: 'missing-workspace' as WorkspaceId,
  303. sessionId: SessionId('session-missing-workspace'),
  304. }))
  305. expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
  306. })
  307. it('retains a published session when attachment fails and repairs it on retry', async () => {
  308. const { api, ctx } = await harness()
  309. const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  310. const workspace = ctx.workspace.list()[0]
  311. if (workspace === undefined) throw new Error('workspace missing from registry')
  312. vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
  313. const sessionId = SessionId('session-attach-retry')
  314. const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))
  315. expect(failed.result).toMatchObject({
  316. ok: false,
  317. error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } },
  318. })
  319. expect(ctx.agents.get(sessionId)).toBeDefined()
  320. expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
  321. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  322. })
  323. })
  324. describe('Host Workspace increments', () => {
  325. it('streams committed Workspace and Session increments after empty baselines', async () => {
  326. const { api } = await harness()
  327. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  328. expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
  329. const abort = new AbortController()
  330. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  331. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  332. const workspaceIncrement = nextHostFrame(stream)
  333. const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  334. expect(await workspaceIncrement).toMatchObject({
  335. payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
  336. })
  337. const sessionId = SessionId('session-streamed-workspace')
  338. const pending = nextHostFrame(stream)
  339. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  340. const increments: HostFrame[] = []
  341. increments.push((await pending).payload)
  342. while (increments.length < 2) {
  343. const next = await stream.next()
  344. if (next.done === true) throw new Error('Host stream ended before both increments')
  345. increments.push(next.value.payload)
  346. }
  347. expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
  348. // A just-created session has no events: the frame constantly carries blank:true.
  349. type: 'host/session-added', sessionId, blank: true, cwd: workspace.path,
  350. })
  351. const workspaceChanged = increments.find(
  352. (increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
  353. increment.type === 'host/workspace-changed',
  354. )
  355. expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId])
  356. abort.abort()
  357. })
  358. it('does not publish a Workspace whose registry-order commit fails', async () => {
  359. const { api, storageDomain } = await harness()
  360. const domain = storageDomain.get('workspace')
  361. if (domain === undefined) throw new Error('workspace domain is not open')
  362. vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
  363. const abort = new AbortController()
  364. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  365. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  366. const next = stream.next()
  367. const failed = await api.workspace.create(request({ name: 'ghost' }))
  368. expect(failed.result.ok).toBe(false)
  369. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  370. abort.abort()
  371. expect(await next).toMatchObject({ done: true })
  372. })
  373. it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
  374. const { api, ctx } = await harness()
  375. const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
  376. const sessionId = SessionId('session-kept-after-workspace-delete')
  377. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  378. const abort = new AbortController()
  379. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  380. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  381. const removed = nextHostFrame(stream)
  382. expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
  383. expect(await removed).toMatchObject({
  384. payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
  385. })
  386. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  387. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
  388. expect(ctx.agents.get(sessionId)).toBeDefined()
  389. expect(existsSync(workspace.path)).toBe(true)
  390. const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
  391. expect(missing.result).toMatchObject({
  392. ok: false,
  393. error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
  394. })
  395. const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
  396. expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
  397. expect(reregistered.path).toBe(workspace.path)
  398. expect(reregistered.sessionIds).toEqual([])
  399. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
  400. abort.abort()
  401. })
  402. it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
  403. const { api } = await harness()
  404. const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
  405. const sessionId = SessionId('session-to-archive')
  406. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  407. expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
  408. const abort = new AbortController()
  409. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  410. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  411. const changed = nextHostFrame(stream)
  412. expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
  413. .toEqual([sessionId])
  414. expect(await changed).toMatchObject({
  415. payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] },
  416. })
  417. // Accounting and the session itself are untouched; list re-baselines the set.
  418. const listed = expectOk(await api.workspace.list(request({})))
  419. expect(listed.archivedSessionIds).toEqual([sessionId])
  420. expect(listed.items[0]?.sessionIds).toEqual([sessionId])
  421. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
  422. // The idempotent repeat emits no second frame: the next observed frame is
  423. // the workspace-changed of a later attach, not another archive snapshot.
  424. const after = nextHostFrame(stream)
  425. expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
  426. .toEqual([sessionId])
  427. const otherSession = SessionId('session-after-archive')
  428. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession })))
  429. expect((await after).payload.type).not.toBe('host/archived-sessions-changed')
  430. const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') }))
  431. expect(missing.result).toMatchObject({
  432. ok: false,
  433. error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } },
  434. })
  435. abort.abort()
  436. })
  437. })