built-lib.e2e.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { execFile } from 'node:child_process'
  2. import { existsSync } from 'node:fs'
  3. import { join, resolve } from 'node:path'
  4. import { fileURLToPath, pathToFileURL } from 'node:url'
  5. import { describe, expect, it } from 'vitest'
  6. /**
  7. * Built-artifact smoke for the first generated Remote: plain Node boots the
  8. * Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route.
  9. */
  10. const packageDir = fileURLToPath(new URL('..', import.meta.url))
  11. const root = resolve(packageDir, '../../..')
  12. const artifact = (path: string): string => join(root, path)
  13. const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href
  14. const requiredArtifacts = [
  15. 'packages/client/connection/lib/client.js',
  16. 'packages/client/connection/lib/index.js',
  17. 'packages/api/remotes/lib/client.js',
  18. 'packages/core/agent/lib/index.js',
  19. 'packages/core/session/lib/index.js',
  20. 'packages/goal/goal/lib/index.js',
  21. 'packages/goal/goal/lib/typert.host.js',
  22. 'packages/api/gateway/lib/client.js',
  23. 'packages/api/gateway/lib/index.js',
  24. 'packages/typert/registry/lib/client.js',
  25. 'packages/typert/registry/lib/index.js',
  26. 'packages/session/session-projection/lib/index.js',
  27. ].every(path => existsSync(artifact(path)))
  28. describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
  29. it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => {
  30. const urls = Object.fromEntries(Object.entries({
  31. agent: 'packages/core/agent/lib/index.js',
  32. apiGatewayClient: 'packages/api/gateway/lib/client.js',
  33. apiGatewayHost: 'packages/api/gateway/lib/index.js',
  34. connectionClient: 'packages/client/connection/lib/client.js',
  35. connectionHost: 'packages/client/connection/lib/index.js',
  36. goal: 'packages/goal/goal/lib/index.js',
  37. goalTypert: 'packages/goal/goal/lib/typert.host.js',
  38. registryClient: 'packages/typert/registry/lib/client.js',
  39. registryHost: 'packages/typert/registry/lib/index.js',
  40. remotesClient: 'packages/api/remotes/lib/client.js',
  41. session: 'packages/core/session/lib/index.js',
  42. sessionProjections: 'packages/session/session-projection/lib/index.js',
  43. }).map(([key, path]) => [key, artifactUrl(path)]))
  44. const script = `
  45. import { createServer } from 'node:http'
  46. import * as cordis from '@deepseek-ai/cordis'
  47. import * as zod from 'zod'
  48. const urls = ${JSON.stringify(urls)}
  49. const { Context } = cordis
  50. const { default: AgentRegistry } = await import(urls.agent)
  51. const connectionHost = await import(urls.connectionHost)
  52. const { default: TypertRemoteService } = await import(urls.apiGatewayHost)
  53. const { default: GoalService } = await import(urls.goal)
  54. const { default: SessionProjectionRegistry } = await import(urls.sessionProjections)
  55. const { TYPERT } = await import(urls.goalTypert)
  56. const { default: TypertRegistry } = await import(urls.registryHost)
  57. const { Session, SessionId } = await import(urls.session)
  58. const routes = []
  59. const credentialRecords = new Map()
  60. const host = new Context()
  61. host.provide('webServer', {
  62. register(route) {
  63. routes.push(route)
  64. return () => { routes.splice(routes.indexOf(route), 1) }
  65. },
  66. tapIndex() { return () => {} },
  67. port: 0,
  68. })
  69. host.provide('credentials', {
  70. readRecord(key) { return Promise.resolve(credentialRecords.get(key)) },
  71. async modifyRecord(key, mutate) {
  72. const current = credentialRecords.get(key)
  73. const next = await mutate(current)
  74. if (next !== undefined) credentialRecords.set(key, next)
  75. return next ?? current
  76. },
  77. })
  78. await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply })
  79. await host.plugin(TypertRegistry)
  80. await host.plugin(AgentRegistry)
  81. await host.plugin(TypertRemoteService)
  82. await host.plugin(SessionProjectionRegistry)
  83. await host.plugin(GoalService)
  84. host.typert.register(TYPERT)
  85. const makeAgent = rawId => {
  86. const session = new Session(SessionId(rawId))
  87. return {
  88. id: session.id,
  89. options: {},
  90. session,
  91. ctx: host.extend(),
  92. status: 'idle',
  93. acceptsNextStep: false,
  94. send() {},
  95. updateInbox() { return 'not-found' },
  96. followup() {},
  97. steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } },
  98. inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) },
  99. reserveTurnAdmission() {},
  100. cancel() {},
  101. whenIdle() { return Promise.resolve() },
  102. }
  103. }
  104. const rootAgent = makeAgent('built-root-agent')
  105. const scopedAgent = makeAgent('built-scoped-agent')
  106. host.agents.register(rootAgent)
  107. host.agents.register(scopedAgent)
  108. if (routes.length !== 1 || routes[0].path !== '/api') {
  109. throw new Error('Connection did not register exactly one /api route')
  110. }
  111. const server = createServer((request, response) => {
  112. if ((request.url ?? '/').startsWith('/?')) {
  113. if (host.connection.authorizeIndex(request, response)) {
  114. response.writeHead(200, { 'content-type': 'text/html' })
  115. response.end('<body>shell</body>')
  116. }
  117. return
  118. }
  119. void routes[0].handler(request, response)
  120. })
  121. await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
  122. const address = server.address()
  123. if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
  124. const origin = 'http://127.0.0.1:' + String(address.port)
  125. const login = await fetch(host.connection.authenticatedUrl(origin), { redirect: 'manual' })
  126. const setCookie = login.headers.get('set-cookie')
  127. if (login.status !== 303 || setCookie === null) throw new Error('browser token exchange failed')
  128. const cookie = setCookie.split(';', 1)[0]
  129. const hostFetch = globalThis.fetch
  130. globalThis.fetch = (input, init = {}) => {
  131. const headers = new Headers(init.headers)
  132. headers.set('cookie', cookie)
  133. return hostFetch(input, { ...init, headers })
  134. }
  135. const handoffs = new Map()
  136. globalThis.window = {
  137. __ModuleLoader__: {
  138. load(handoff) { handoffs.set(handoff.id, handoff) },
  139. },
  140. }
  141. globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
  142. await import(urls.registryClient)
  143. await import(urls.connectionClient)
  144. await import(urls.apiGatewayClient)
  145. await import(urls.remotesClient)
  146. const instantiate = id => {
  147. const handoff = handoffs.get(id)
  148. if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
  149. return handoff.factory(specifier => {
  150. if (specifier === '@deepseek-ai/cordis') return cordis
  151. if (specifier === 'zod') return zod
  152. throw new Error('unexpected Client external ' + specifier)
  153. })
  154. }
  155. const client = new Context()
  156. for (const id of [
  157. '@deepseek-ai/dsh-typert-registry',
  158. '@deepseek-ai/dsh-client-connection',
  159. '@deepseek-ai/dsh-api-gateway',
  160. '@deepseek-ai/dsh-api-remotes',
  161. ]) {
  162. const plugin = instantiate(id)
  163. await client.plugin({ inject: plugin.inject, apply: plugin.apply })
  164. }
  165. client.typert.contexts.registerClient('agent', {
  166. identity: candidate => candidate.builtAgentId,
  167. })
  168. let invalidRejected = false
  169. try {
  170. await client.remote.goals.create(rootAgent.id, { objective: 1 })
  171. } catch {
  172. invalidRejected = true
  173. }
  174. // Every generated method resolves to the RemoteResult envelope; the
  175. // business values below are what the assertions pin.
  176. const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
  177. const rootEdit = await client.remote.goals.edit(
  178. rootAgent.id,
  179. rootResult.value.ref,
  180. { objective: 'edited root goal' },
  181. )
  182. const agentContext = client.extend({ builtAgentId: scopedAgent.id })
  183. const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
  184. const result = {
  185. invalidRejected,
  186. rootResult: rootResult.value,
  187. rootEdit: rootEdit.value,
  188. scopedResult: scopedResult.value,
  189. rootGoal: host.goals.get(rootAgent)?.objective,
  190. scopedGoal: host.goals.get(scopedAgent)?.objective,
  191. rootEvents: rootAgent.session.snapshotEvents().length,
  192. scopedEvents: scopedAgent.session.snapshotEvents().length,
  193. }
  194. await client.fiber.dispose()
  195. await new Promise((resolveClose, rejectClose) => server.close(error => {
  196. if (error === undefined) resolveClose()
  197. else rejectClose(error)
  198. }))
  199. await host.fiber.dispose()
  200. console.log(JSON.stringify(result))
  201. `
  202. const result = await runPlainNode(script)
  203. expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
  204. const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
  205. invalidRejected: boolean
  206. rootResult: { ref: { id: string; revision: number } }
  207. rootEdit: { objective: string; revision: number }
  208. scopedResult: { ref: { id: string; revision: number } }
  209. rootGoal: string
  210. scopedGoal: string
  211. rootEvents: number
  212. scopedEvents: number
  213. }
  214. expect(output).toMatchObject({
  215. invalidRejected: true,
  216. rootResult: { ref: { revision: 1 } },
  217. rootEdit: { objective: 'edited root goal', revision: 2 },
  218. scopedResult: { ref: { revision: 1 } },
  219. rootGoal: 'edited root goal',
  220. scopedGoal: 'scoped goal',
  221. rootEvents: 2,
  222. scopedEvents: 1,
  223. })
  224. expect(output.rootResult.ref.id).toMatch(/^goal-/)
  225. expect(output.scopedResult.ref.id).toMatch(/^goal-/)
  226. }, 60_000)
  227. })
  228. /** Execute one ESM script without tsx or a TypeScript loader. */
  229. function runPlainNode(script: string): Promise<{
  230. readonly exitCode: number | null
  231. readonly stdout: string
  232. readonly stderr: string
  233. }> {
  234. return new Promise((resolveRun) => {
  235. execFile(process.execPath, ['--input-type=module', '-e', script], {
  236. cwd: packageDir,
  237. encoding: 'utf8',
  238. timeout: 55_000,
  239. }, (error, stdout, stderr) => {
  240. resolveRun({
  241. exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
  242. stdout,
  243. stderr,
  244. })
  245. })
  246. })
  247. }