commands-create-fork.host.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry from '@deepseek-ai/dsh-agent'
  3. import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
  4. import type {} from '@deepseek-ai/dsh-agent-presets'
  5. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  6. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  7. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  8. import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import {
  11. ApiSessionAgentController,
  12. ApiSessionCwdConflict,
  13. } from '../src/agent.ts'
  14. import { SessionCommandController } from '../src/commands.ts'
  15. import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
  16. async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
  17. await expect(operation).rejects.toMatchObject({ code })
  18. }
  19. function controllerAgents(overrides: object = {}): ApiSessionAgentController {
  20. return {
  21. ensureSession: () => Promise.resolve(),
  22. composeAgent: () => Promise.resolve({ setup: () => {} }),
  23. presetForSession: () => undefined,
  24. presetForObservation: () => undefined,
  25. ...overrides,
  26. } as unknown as ApiSessionAgentController
  27. }
  28. async function baseContext(): Promise<Context> {
  29. const ctx = new Context()
  30. await ctx.plugin(SessionStore)
  31. await ctx.plugin(AgentRegistry)
  32. installSessionReadTestServices(ctx)
  33. ctx.provide('agentDefaultModel', {
  34. currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
  35. saveSelection: () => Promise.resolve(),
  36. } as never)
  37. return ctx
  38. }
  39. describe('Session creation failures', () => {
  40. it('mints an identity with the default cwd when no explicit target is supplied', async () => {
  41. const ctx = await baseContext()
  42. ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
  43. const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => {
  44. const session = ctx.sessions.create(sessionId, { meta: { cwd } })
  45. return Promise.resolve({ id: sessionId, session } as Agent)
  46. })
  47. const controller = new SessionCommandController(
  48. ctx,
  49. controllerAgents({ ensureSession }),
  50. '/default-workspace',
  51. )
  52. const created = await controller.create({})
  53. expect(created.sessionId).toMatch(/^session-/)
  54. expect(created).not.toHaveProperty('agentPreset')
  55. expect(ensureSession).toHaveBeenCalledWith(
  56. created.sessionId,
  57. '/default-workspace',
  58. false,
  59. undefined,
  60. )
  61. await ctx.fiber.dispose()
  62. })
  63. it('maps missing Workspaces and attachment failures', async () => {
  64. const missing = await baseContext()
  65. missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
  66. const missingController = new SessionCommandController(
  67. missing,
  68. controllerAgents(),
  69. '/default',
  70. )
  71. await expectFailure(missingController.create({
  72. workspaceId: 'missing' as WorkspaceId,
  73. }), 'workspace/not-found')
  74. await missing.fiber.dispose()
  75. const failed = await baseContext()
  76. const workspace = {
  77. id: 'workspace-1' as WorkspaceId,
  78. path: '/workspace',
  79. attachSession: () => Promise.reject(new Error('read-only workspace')),
  80. } as unknown as Workspace
  81. failed.provide('workspaceRegistry', {
  82. get: () => workspace,
  83. list: () => [workspace],
  84. } as never)
  85. const failedController = new SessionCommandController(
  86. failed,
  87. controllerAgents(),
  88. '/default',
  89. )
  90. await expectFailure(failedController.create({
  91. sessionId: SessionId('workspace-session'),
  92. workspaceId: workspace.id,
  93. }), 'session/workspace-attach-failed')
  94. await failed.fiber.dispose()
  95. })
  96. it.each([
  97. {
  98. error: new RemoteError(
  99. 'agent-preset/invalid',
  100. 'agent-presets: preset "broken" failed to mount: invalid composition',
  101. { agentPreset: 'broken', reason: 'invalid composition' },
  102. ),
  103. code: 'agent-preset/invalid',
  104. },
  105. {
  106. error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined),
  107. code: 'session/conflict',
  108. },
  109. {
  110. error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'),
  111. code: 'session/conflict',
  112. },
  113. {
  114. error: new Error('factory unavailable'),
  115. code: 'gateway/internal',
  116. },
  117. ])('maps $code creation failures', async ({ error, code }) => {
  118. const ctx = await baseContext()
  119. ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
  120. const controller = new SessionCommandController(
  121. ctx,
  122. controllerAgents({ ensureSession: () => Promise.reject(error) }),
  123. '/default',
  124. )
  125. await expectFailure(controller.create({
  126. sessionId: SessionId('failed-create'), cwd: '/requested',
  127. }), code)
  128. await ctx.fiber.dispose()
  129. })
  130. it('rejects contradictory create targets', async () => {
  131. const ctx = await baseContext()
  132. const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
  133. await expectFailure(controller.create({
  134. workspaceId: 'workspace-1' as WorkspaceId,
  135. cwd: '/workspace',
  136. }), 'gateway/bad-request')
  137. await ctx.fiber.dispose()
  138. })
  139. })
  140. function completedSession(
  141. ctx: Context,
  142. id: string,
  143. cwd?: string,
  144. lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
  145. ) {
  146. const session = ctx.sessions.create(SessionId(id), {
  147. meta: { ...(cwd === undefined ? {} : { cwd }), ...lineage },
  148. })
  149. session.append('turn/start', { turn: 1 })
  150. session.append('user/message', createUserMessage({
  151. content: [{ type: 'text', text: 'work' }], source: { kind: 'user' },
  152. }), { surfaceOp: 'append' })
  153. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  154. return session
  155. }
  156. function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle {
  157. return {
  158. agent: { id: sessionId, status: 'idle', ctx } as Agent,
  159. dispose: () => Promise.resolve(),
  160. }
  161. }
  162. describe('Session fork failures', () => {
  163. it('maps missing cold sources with and without persistence', async () => {
  164. const withoutPersistence = await baseContext()
  165. withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never)
  166. const unavailableController = new SessionCommandController(
  167. withoutPersistence, controllerAgents(), '/default',
  168. )
  169. await expectFailure(unavailableController.fork({
  170. sessionId: SessionId('missing'),
  171. }), 'session/not-found')
  172. await withoutPersistence.fiber.dispose()
  173. const missing = await baseContext()
  174. missing.provide('workspaceRegistry', { list: () => [] } as never)
  175. missing.provide('sessionPersistence', testSessionPersistence(missing, {
  176. list: () => Promise.resolve([]),
  177. inspect: vi.fn(),
  178. }) as never)
  179. const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
  180. await expectFailure(missingController.fork({
  181. sessionId: SessionId('missing'),
  182. }), 'session/not-found')
  183. await missing.fiber.dispose()
  184. })
  185. it('maps an observation failure to an internal fork error', async () => {
  186. const ctx = await baseContext()
  187. ctx.provide('workspaceRegistry', { list: () => [] } as never)
  188. vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
  189. const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
  190. await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal')
  191. await ctx.fiber.dispose()
  192. })
  193. it('rejects a Session with no completed turn', async () => {
  194. const ctx = await baseContext()
  195. ctx.provide('workspaceRegistry', { list: () => [] } as never)
  196. const source = ctx.sessions.create(SessionId('empty-source'))
  197. const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
  198. await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable')
  199. await ctx.fiber.dispose()
  200. })
  201. it('maps lineage lookup and Agent creation failures', async () => {
  202. const lineage = await baseContext()
  203. lineage.provide('workspaceRegistry', { list: () => [] } as never)
  204. vi.spyOn(lineage.sessionQuery, 'traceSession')
  205. .mockRejectedValue(new Error('lineage unavailable'))
  206. const child = completedSession(lineage, 'subagent-source', '/workspace', {
  207. parentSession: SessionId('parent'),
  208. origin: 'subagent',
  209. })
  210. const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default')
  211. await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal')
  212. await lineage.fiber.dispose()
  213. const creation = await baseContext()
  214. creation.provide('workspaceRegistry', { list: () => [] } as never)
  215. const source = completedSession(creation, 'creation-source', '/workspace')
  216. vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed'))
  217. const creationController = new SessionCommandController(creation, controllerAgents(), '/default')
  218. await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal')
  219. await creation.fiber.dispose()
  220. })
  221. it('omits absent cwd and preset metadata before reporting Workspace attachment failure', async () => {
  222. const ctx = await baseContext()
  223. const source = completedSession(ctx, 'workspace-source')
  224. const workspace = {
  225. id: 'workspace-1' as WorkspaceId,
  226. sessionIds: [source.id],
  227. attachSession: () => Promise.reject(new Error('workspace write failed')),
  228. } as unknown as Workspace
  229. ctx.provide('workspaceRegistry', { list: () => [workspace] } as never)
  230. const create = vi.spyOn(ctx.agents, 'create').mockImplementation(
  231. (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)),
  232. )
  233. const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
  234. await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed')
  235. const options = create.mock.calls[0]?.[0]
  236. if (options === undefined) throw new Error('Agent creation was not attempted')
  237. expect(options.meta).not.toHaveProperty('cwd')
  238. expect(options.meta).not.toHaveProperty('agentPreset')
  239. await ctx.fiber.dispose()
  240. })
  241. it('carries the composed Agent preset into the child metadata', async () => {
  242. const ctx = await baseContext()
  243. ctx.provide('workspaceRegistry', { list: () => [] } as never)
  244. const source = completedSession(ctx, 'preset-source', '/workspace')
  245. const create = vi.spyOn(ctx.agents, 'create').mockImplementation(
  246. (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)),
  247. )
  248. const controller = new SessionCommandController(ctx, controllerAgents({
  249. composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }),
  250. }), '/default')
  251. const forked = await controller.fork({ sessionId: source.id })
  252. expect(forked.sessionId).toMatch(/^session-/)
  253. const options = create.mock.calls[0]?.[0]
  254. if (options === undefined) throw new Error('Agent creation was not attempted')
  255. expect(options.meta?.agentPreset).toBe('minimal')
  256. await ctx.fiber.dispose()
  257. })
  258. })