api-proxy-workspace.spec.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  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, { Inbox } 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 {
  24. rpcId: RpcId(`workspace-${String(nextRpc++)}`),
  25. payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload },
  26. }
  27. }
  28. function expectOk<T>(response: RpcResponse<T>): T {
  29. expect(response.result.ok).toBe(true)
  30. if (!response.result.ok) throw new Error('unreachable')
  31. return response.result.value
  32. }
  33. async function nextHostFrame(
  34. stream: AsyncIterator<RpcRequest<HostFrame>>,
  35. ): Promise<RpcRequest<HostFrame>> {
  36. const next = await stream.next()
  37. if (next.done === true) throw new Error('Host stream ended before the expected increment')
  38. return next.value
  39. }
  40. function stubAgent(session: Session): Agent {
  41. return {
  42. id: session.id,
  43. options: {},
  44. session,
  45. inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  46. status: 'idle',
  47. ctx: new Context(),
  48. send: () => {},
  49. followup: () => {},
  50. steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
  51. inject: () => {},
  52. cancel() {},
  53. runMaintenance: task => task(new AbortController().signal),
  54. whenIdle: () => Promise.resolve(),
  55. }
  56. }
  57. /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
  58. async function harness(
  59. workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
  60. picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
  61. extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
  62. ) {
  63. const ctx = new Context()
  64. await ctx.plugin(SessionStore)
  65. await ctx.plugin(AgentRegistry)
  66. await ctx.plugin(UserInteractionService)
  67. await ctx.plugin(Storage)
  68. ctx.storage.backend.register('memory', new MemoryStorageBackend())
  69. const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
  70. ctx.storage.mount('domain', storageDomain)
  71. ctx.provide('storageDomain', storageDomain)
  72. ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
  73. await ctx.plugin(WorkspaceRegistry)
  74. const factory: AgentFactory = {
  75. async createAgent(_ownerCtx, options) {
  76. const session = ctx.sessions.create(
  77. options.sessionId,
  78. options.meta === undefined ? {} : { meta: options.meta },
  79. )
  80. const agent = stubAgent(session)
  81. const unregister = ctx.agents.register(agent)
  82. return {
  83. agent,
  84. dispose: () => {
  85. unregister()
  86. return Promise.resolve()
  87. },
  88. }
  89. },
  90. async resume() {
  91. throw new Error('test harness has no persisted sessions')
  92. },
  93. }
  94. ctx.agents.setFactory(factory)
  95. // Structural picker fake: the gateway only reads capability(); a stable
  96. // object per harness mirrors the seam's stability contract.
  97. ctx.provide('directoryPicker', { capability: () => picker } as never)
  98. const api = createApiProxy(ctx, {
  99. defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
  100. cwd: workspaceRoot,
  101. workspaceRoot,
  102. ...extras.openPath === undefined ? {} : { openPath: extras.openPath },
  103. })
  104. return { api, ctx, storageDomain, workspaceRoot }
  105. }
  106. describe('host.pickDirectory', () => {
  107. it('returns a selected path or explicit cancellation from the native capability', async () => {
  108. const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' })
  109. expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
  110. .toEqual({ ok: true, value: { path: '/tmp/project' } })
  111. const cancelled = await harness(undefined, { kind: 'native', pick: async () => null })
  112. expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
  113. .toEqual({ ok: true, value: { path: null } })
  114. })
  115. it('propagates abort into the native capability as a cancelled RPC error', async () => {
  116. const { api } = await harness(undefined, {
  117. kind: 'native',
  118. pick: signal => new Promise((_resolve, reject) => {
  119. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  120. }),
  121. })
  122. const abort = new AbortController()
  123. const pending = api.host.pickDirectory(request({}), abort.signal)
  124. abort.abort()
  125. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  126. })
  127. it('folds a non-abort native-chooser failure into an internal error', async () => {
  128. const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
  129. const response = await api.host.pickDirectory(request({}), new AbortController().signal)
  130. expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
  131. })
  132. it('refuses the native RPC under a browse composition', async () => {
  133. const { api } = await harness(undefined, BROWSE_STUB)
  134. const response = await api.host.pickDirectory(request({}), new AbortController().signal)
  135. expect(response.result).toMatchObject({
  136. ok: false,
  137. error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } },
  138. })
  139. })
  140. })
  141. /** Canned browse capability: one listing, one created path, typed failures on demand. */
  142. const BROWSE_STUB: DirectoryPickerCapability = {
  143. kind: 'browse',
  144. list: async (path) => {
  145. if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
  146. const target = path ?? '/home/user'
  147. return {
  148. path: target,
  149. home: '/home/user',
  150. crumbs: [{ name: '/', path: '/', hidden: false }],
  151. entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
  152. truncated: false,
  153. }
  154. },
  155. createDirectory: async (path, name) => {
  156. if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
  157. if (name === 'unwritable') throw new Error('disk detached')
  158. return `${path}/${name}`
  159. },
  160. }
  161. describe('host.listDirectory / host.createDirectory', () => {
  162. it('serves listings and creation through the browse capability, defaulting to home', async () => {
  163. const { api } = await harness(undefined, BROWSE_STUB)
  164. const home = await api.host.listDirectory(request({}), new AbortController().signal)
  165. expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
  166. const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal)
  167. expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
  168. const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
  169. expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
  170. })
  171. it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => {
  172. const { api } = await harness(undefined, BROWSE_STUB)
  173. expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({
  174. ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } },
  175. })
  176. expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({
  177. ok: false, error: { code: 'directory-exists' },
  178. })
  179. expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({
  180. ok: false, error: { code: 'internal' },
  181. })
  182. })
  183. it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => {
  184. const { api } = await harness(undefined, {
  185. kind: 'browse',
  186. list: (_path, signal) => new Promise((_resolve, reject) => {
  187. signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
  188. }),
  189. createDirectory: async () => '/never',
  190. })
  191. const abort = new AbortController()
  192. const pending = api.host.listDirectory(request({}), abort.signal)
  193. abort.abort()
  194. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  195. })
  196. it('refuses the browse RPCs under a native composition', async () => {
  197. const { api } = await harness()
  198. expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({
  199. ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
  200. })
  201. expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
  202. ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
  203. })
  204. })
  205. })
  206. describe('host.openPath', () => {
  207. it('opens through the injected native boundary', async () => {
  208. const opened: string[] = []
  209. const { api } = await harness(undefined, undefined, {
  210. openPath: async (path) => { opened.push(path) },
  211. })
  212. expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
  213. .toEqual({ ok: true, value: { opened: true } })
  214. expect(opened).toEqual(['/tmp/a.txt'])
  215. })
  216. it('propagates abort into the native boundary as a cancelled RPC error', async () => {
  217. const { api } = await harness(undefined, undefined, {
  218. openPath: (_path, signal) => new Promise((_resolve, reject) => {
  219. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  220. }),
  221. })
  222. const abort = new AbortController()
  223. const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
  224. abort.abort()
  225. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  226. })
  227. })
  228. describe('workspace.create', () => {
  229. it('serializes concurrent names and rejects the duplicate', async () => {
  230. const { api, workspaceRoot } = await harness()
  231. const responses = await Promise.all([
  232. api.workspace.create(request({ name: 'alpha' })),
  233. api.workspace.create(request({ name: 'alpha' })),
  234. ])
  235. const created = responses.find(response => response.result.ok)
  236. const duplicate = responses.find(response => !response.result.ok)
  237. expect(created).toBeDefined()
  238. expect(expectOk(created!)).toMatchObject({
  239. created: true,
  240. workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
  241. })
  242. expect(duplicate?.result).toMatchObject({
  243. ok: false,
  244. error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
  245. })
  246. expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
  247. })
  248. it('adopts only existing directories and rejects unsafe names', async () => {
  249. const { api, workspaceRoot } = await harness()
  250. const existing = join(workspaceRoot, 'existing')
  251. mkdirSync(existing)
  252. const first = expectOk(await api.workspace.create(request({ path: existing })))
  253. const repeated = expectOk(await api.workspace.create(request({ path: existing })))
  254. expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
  255. expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
  256. expectOk(await api.workspace.rename(request({
  257. workspaceId: first.workspace.workspaceId,
  258. title: 'renamed-existing',
  259. })))
  260. const reopened = expectOk(await api.workspace.create(request({ path: existing })))
  261. expect(reopened.workspace.title).toBe('renamed-existing')
  262. const missing = join(workspaceRoot, 'missing')
  263. const missingResult = await api.workspace.create(request({ path: missing }))
  264. expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
  265. expect(existsSync(missing)).toBe(false)
  266. for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
  267. const invalid = await api.workspace.create(request({ name }))
  268. expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
  269. }
  270. })
  271. it('adopts different paths that derive the same Workspace title', async () => {
  272. const { api, workspaceRoot } = await harness()
  273. const first = join(workspaceRoot, 'one', 'project')
  274. const second = join(workspaceRoot, 'two', 'project')
  275. mkdirSync(first, { recursive: true })
  276. mkdirSync(second, { recursive: true })
  277. const firstResult = expectOk(await api.workspace.create(request({ path: first })))
  278. const secondResult = expectOk(await api.workspace.create(request({ path: second })))
  279. expect(firstResult).toMatchObject({
  280. created: true,
  281. workspace: { path: first, title: 'project' },
  282. })
  283. expect(secondResult).toMatchObject({
  284. created: true,
  285. workspace: { path: second, title: 'project' },
  286. })
  287. expect(secondResult.workspace.workspaceId).not.toBe(firstResult.workspace.workspaceId)
  288. expect(expectOk(await api.workspace.list(request({}))).items.map(workspace => workspace.path))
  289. .toEqual([second, first])
  290. })
  291. })
  292. describe('session creation and Workspace membership', () => {
  293. it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
  294. const { api, ctx } = await harness()
  295. const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  296. const sessionId = SessionId('session-workspace-preallocated')
  297. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  298. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  299. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  300. expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1)
  301. const ungrouped = SessionId('session-cwd-only')
  302. expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped })))
  303. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  304. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped)
  305. const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId }))
  306. expect(conflict.result).toMatchObject({
  307. ok: false,
  308. error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } },
  309. })
  310. const missing = await api.sessions.create(request({
  311. workspaceId: 'missing-workspace' as WorkspaceId,
  312. sessionId: SessionId('session-missing-workspace'),
  313. }))
  314. expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
  315. })
  316. it('retains a published session when attachment fails and repairs it on retry', async () => {
  317. const { api, ctx } = await harness()
  318. const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  319. const workspace = ctx.workspace.list()[0]
  320. if (workspace === undefined) throw new Error('workspace missing from registry')
  321. vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
  322. const sessionId = SessionId('session-attach-retry')
  323. const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))
  324. expect(failed.result).toMatchObject({
  325. ok: false,
  326. error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } },
  327. })
  328. expect(ctx.agents.get(sessionId)).toBeDefined()
  329. expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
  330. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  331. })
  332. it('canonicalizes the immutable Session zone and rejects identity conflicts', async () => {
  333. const { api, ctx, workspaceRoot } = await harness()
  334. const sessionId = SessionId('session-zone-identity')
  335. const alias = 'US/Eastern'
  336. const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
  337. .resolvedOptions().timeZone
  338. expectOk(await api.sessions.create(request({ sessionId, cwd: workspaceRoot, timeZone: alias })))
  339. expect(ctx.agents.get(sessionId)?.session.header.timeZone).toBe(canonical)
  340. expectOk(await api.sessions.create(request({ sessionId, cwd: workspaceRoot, timeZone: canonical })))
  341. const conflict = await api.sessions.create(request({
  342. sessionId,
  343. cwd: workspaceRoot,
  344. timeZone: 'Asia/Shanghai',
  345. }))
  346. expect(conflict.result).toMatchObject({
  347. ok: false,
  348. error: {
  349. code: 'session-conflict',
  350. details: {
  351. sessionId,
  352. requestedCwd: workspaceRoot,
  353. requestedTimeZone: 'Asia/Shanghai',
  354. existingTimeZone: canonical,
  355. },
  356. },
  357. })
  358. })
  359. it('keeps a live headerless Session compatible without absorbing a request zone', async () => {
  360. const { api, ctx, workspaceRoot } = await harness()
  361. const session = ctx.sessions.create(SessionId('session-zone-headerless'), {
  362. meta: { cwd: workspaceRoot },
  363. })
  364. ctx.agents.register(stubAgent(session))
  365. expectOk(await api.sessions.create(request({
  366. sessionId: session.id,
  367. cwd: workspaceRoot,
  368. timeZone: 'Asia/Shanghai',
  369. })))
  370. expect(session.header.timeZone).toBeUndefined()
  371. })
  372. it('serializes different-zone creates so the first immutable identity wins', async () => {
  373. const { api, ctx, workspaceRoot } = await harness()
  374. const sessionId = SessionId('session-zone-race')
  375. const first = api.sessions.create(request({
  376. sessionId,
  377. cwd: workspaceRoot,
  378. timeZone: 'UTC',
  379. }))
  380. const second = api.sessions.create(request({
  381. sessionId,
  382. cwd: workspaceRoot,
  383. timeZone: 'Asia/Shanghai',
  384. }))
  385. const [firstResult, secondResult] = await Promise.all([first, second])
  386. expect(firstResult.result).toMatchObject({ ok: true, value: { sessionId } })
  387. expect(secondResult.result).toMatchObject({
  388. ok: false,
  389. error: { code: 'session-conflict', details: { existingTimeZone: 'UTC' } },
  390. })
  391. expect(ctx.agents.get(sessionId)?.session.header.timeZone).toBe('UTC')
  392. })
  393. it.each([
  394. [undefined, null],
  395. ['', ''],
  396. [' UTC', ' UTC'],
  397. ['CST', 'CST'],
  398. ['GMT', 'GMT'],
  399. ['+08:00', '+08:00'],
  400. ['Not/A_Real_Zone', 'Not/A_Real_Zone'],
  401. ] as const)('rejects invalid Session zone input %j before Agent creation', async (timeZone, value) => {
  402. const { api, ctx } = await harness()
  403. const response = await api.sessions.create(request({ timeZone }))
  404. expect(response.result).toMatchObject({
  405. ok: false,
  406. error: { code: 'invalid-time-zone', details: { field: 'timeZone', value } },
  407. })
  408. expect(ctx.agents.list()).toHaveLength(0)
  409. })
  410. it('binds each canonical client zone to its own queued or steering message source', async () => {
  411. const { api, ctx } = await harness()
  412. const sessionId = expectOk(await api.sessions.create(request({ timeZone: 'UTC' }))).sessionId
  413. const agent = ctx.agents.get(sessionId)
  414. if (agent === undefined) throw new Error('created Agent missing')
  415. const followup = vi.spyOn(agent, 'followup')
  416. const steer = vi.spyOn(agent, 'steer')
  417. const alias = 'US/Eastern'
  418. const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
  419. .resolvedOptions().timeZone
  420. expectOk(await api.sessions.prompt(request({
  421. sessionId,
  422. mode: 'queue',
  423. content: [{ type: 'text', text: 'queue' }],
  424. clientTimeZone: alias,
  425. })))
  426. expectOk(await api.sessions.prompt(request({
  427. sessionId,
  428. mode: 'steer',
  429. content: [{ type: 'text', text: 'steer' }],
  430. clientTimeZone: 'Asia/Shanghai',
  431. })))
  432. expect(followup.mock.calls[0]?.[0].source).toMatchObject({
  433. kind: 'user',
  434. clientTimeZone: canonical,
  435. })
  436. expect(steer.mock.calls[0]?.[0].source).toMatchObject({
  437. kind: 'user',
  438. clientTimeZone: 'Asia/Shanghai',
  439. })
  440. })
  441. it.each([undefined, '', 'CST', 'Not/A_Real_Zone'] as const)(
  442. 'rejects invalid prompt zone input %j before delivery',
  443. async (clientTimeZone) => {
  444. const { api, ctx } = await harness()
  445. const sessionId = expectOk(await api.sessions.create(request({ timeZone: 'UTC' }))).sessionId
  446. const agent = ctx.agents.get(sessionId)
  447. if (agent === undefined) throw new Error('created Agent missing')
  448. const followup = vi.spyOn(agent, 'followup')
  449. const response = await api.sessions.prompt(request({
  450. sessionId,
  451. mode: 'queue',
  452. content: [{ type: 'text', text: 'rejected' }],
  453. clientTimeZone,
  454. }))
  455. expect(response.result).toMatchObject({
  456. ok: false,
  457. error: {
  458. code: 'invalid-time-zone',
  459. details: { field: 'clientTimeZone', value: clientTimeZone ?? null },
  460. },
  461. })
  462. expect(followup).not.toHaveBeenCalled()
  463. },
  464. )
  465. })
  466. describe('Host Workspace increments', () => {
  467. it('projects subagent origin in attached summaries and creation increments', async () => {
  468. const { api, ctx } = await harness()
  469. const abort = new AbortController()
  470. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  471. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  472. const pending = nextHostFrame(stream)
  473. const childId = SessionId('session-subagent-child')
  474. ctx.sessions.create(childId, {
  475. meta: {
  476. cwd: '/tmp',
  477. parentSession: SessionId('session-parent'),
  478. origin: 'subagent',
  479. },
  480. })
  481. expect(await pending).toMatchObject({
  482. payload: {
  483. type: 'host/session-added',
  484. sessionId: childId,
  485. parentSessionId: 'session-parent',
  486. origin: 'subagent',
  487. },
  488. })
  489. expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual(
  490. expect.objectContaining({ sessionId: childId, origin: 'subagent' }),
  491. )
  492. abort.abort()
  493. })
  494. it('streams committed Workspace and Session increments after empty baselines', async () => {
  495. const { api } = await harness()
  496. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  497. expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
  498. const abort = new AbortController()
  499. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  500. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  501. const workspaceIncrement = nextHostFrame(stream)
  502. const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  503. expect(await workspaceIncrement).toMatchObject({
  504. payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
  505. })
  506. const sessionId = SessionId('session-streamed-workspace')
  507. const pending = nextHostFrame(stream)
  508. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  509. const increments: HostFrame[] = []
  510. increments.push((await pending).payload)
  511. while (increments.length < 2) {
  512. const next = await stream.next()
  513. if (next.done === true) throw new Error('Host stream ended before both increments')
  514. increments.push(next.value.payload)
  515. }
  516. expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
  517. // A just-created session has no events: the frame constantly carries blank:true.
  518. type: 'host/session-added', sessionId, blank: true, cwd: workspace.path,
  519. })
  520. const workspaceChanged = increments.find(
  521. (increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
  522. increment.type === 'host/workspace-changed',
  523. )
  524. expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId])
  525. abort.abort()
  526. })
  527. it('does not publish a Workspace whose registry-order commit fails', async () => {
  528. const { api, storageDomain } = await harness()
  529. const domain = storageDomain.get('workspace')
  530. if (domain === undefined) throw new Error('workspace domain is not open')
  531. vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
  532. const abort = new AbortController()
  533. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  534. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  535. const next = stream.next()
  536. const failed = await api.workspace.create(request({ name: 'ghost' }))
  537. expect(failed.result.ok).toBe(false)
  538. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  539. abort.abort()
  540. expect(await next).toMatchObject({ done: true })
  541. })
  542. it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
  543. const { api, ctx } = await harness()
  544. const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
  545. const sessionId = SessionId('session-kept-after-workspace-delete')
  546. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  547. const abort = new AbortController()
  548. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  549. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  550. const removed = nextHostFrame(stream)
  551. expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
  552. expect(await removed).toMatchObject({
  553. payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
  554. })
  555. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  556. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
  557. expect(ctx.agents.get(sessionId)).toBeDefined()
  558. expect(existsSync(workspace.path)).toBe(true)
  559. const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
  560. expect(missing.result).toMatchObject({
  561. ok: false,
  562. error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
  563. })
  564. const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
  565. expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
  566. expect(reregistered.path).toBe(workspace.path)
  567. expect(reregistered.sessionIds).toEqual([])
  568. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
  569. abort.abort()
  570. })
  571. it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
  572. const { api } = await harness()
  573. const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
  574. const sessionId = SessionId('session-to-archive')
  575. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  576. expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
  577. const abort = new AbortController()
  578. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  579. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  580. const changed = nextHostFrame(stream)
  581. expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
  582. .toEqual([sessionId])
  583. expect(await changed).toMatchObject({
  584. payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] },
  585. })
  586. // Accounting and the session itself are untouched; list re-baselines the set.
  587. const listed = expectOk(await api.workspace.list(request({})))
  588. expect(listed.archivedSessionIds).toEqual([sessionId])
  589. expect(listed.items[0]?.sessionIds).toEqual([sessionId])
  590. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
  591. // The idempotent repeat emits no second frame: the next observed frame is
  592. // the workspace-changed of a later attach, not another archive snapshot.
  593. const after = nextHostFrame(stream)
  594. expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
  595. .toEqual([sessionId])
  596. const otherSession = SessionId('session-after-archive')
  597. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession })))
  598. expect((await after).payload.type).not.toBe('host/archived-sessions-changed')
  599. const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') }))
  600. expect(missing.result).toMatchObject({
  601. ok: false,
  602. error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } },
  603. })
  604. abort.abort()
  605. })
  606. })