persistent-tools.snapshot.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import { createServer } from 'node:http'
  2. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { describe, expect, it } from 'vitest'
  7. import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
  8. import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
  9. const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
  10. const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url))
  11. const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
  12. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  13. const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url))
  14. interface ModelRequest {
  15. messages?: Array<Record<string, unknown>>
  16. tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }>
  17. }
  18. function sseToolCall(id: string, name: string, args: Record<string, unknown>): string[] {
  19. return [
  20. 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n',
  21. `data: ${JSON.stringify({
  22. choices: [{
  23. delta: {
  24. tool_calls: [{
  25. index: 0,
  26. id,
  27. type: 'function',
  28. function: { name, arguments: JSON.stringify(args) },
  29. }],
  30. },
  31. }],
  32. })}\n\n`,
  33. 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n',
  34. 'data: [DONE]\n\n',
  35. ]
  36. }
  37. function sseText(text: string): string[] {
  38. return [
  39. 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n',
  40. `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`,
  41. 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n',
  42. 'data: [DONE]\n\n',
  43. ]
  44. }
  45. function messageText(content: unknown): string {
  46. if (typeof content === 'string') return content
  47. if (!Array.isArray(content)) return ''
  48. return content.flatMap((block) => {
  49. if (typeof block !== 'object' || block === null) return []
  50. const text = (block as { text?: unknown }).text
  51. return typeof text === 'string' ? [text] : []
  52. }).join('')
  53. }
  54. function latestToolCall(messages: Array<Record<string, unknown>>): { id: string; name: string } {
  55. for (const message of messages.toReversed()) {
  56. const calls = message.tool_calls
  57. if (!Array.isArray(calls)) continue
  58. const call = (calls as unknown[]).at(-1)
  59. if (typeof call !== 'object' || call === null) continue
  60. const id = (call as { id?: unknown }).id
  61. const fn = (call as { function?: { name?: unknown } }).function
  62. if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name }
  63. }
  64. throw new Error('model request has no preceding tool call')
  65. }
  66. function normalize(value: string, cwd: string): string {
  67. return value.replaceAll(cwd, '{{cwd}}')
  68. }
  69. describe('jsonrpc persistent tools snapshot', () => {
  70. it('runs persistent shell state and editor mutations keylessly', async () => {
  71. const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-'))
  72. const sessionRoot = join(cwd, '.sessions')
  73. const target = join(cwd, 'note.txt')
  74. const requests: ModelRequest[] = []
  75. const modelServer = createServer((request, response) => {
  76. let body = ''
  77. request.setEncoding('utf8')
  78. request.on('data', (chunk: string) => { body += chunk })
  79. request.on('end', () => {
  80. const parsed = JSON.parse(body) as ModelRequest
  81. requests.push(parsed)
  82. const messages = parsed.messages ?? []
  83. const latest = messages.at(-1)
  84. if (latest === undefined) throw new Error('model request has no messages')
  85. let chunks: string[]
  86. if (latest.role !== 'tool') {
  87. chunks = sseToolCall('bash-1', 'bash', {
  88. command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"',
  89. })
  90. } else {
  91. const call = latestToolCall(messages)
  92. const toolText = messageText(latest.content)
  93. if (call.id === 'bash-1') {
  94. expect(toolText).toContain('COUNT=1 CWD=/tmp')
  95. chunks = sseToolCall('bash-2', 'bash', {
  96. command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"',
  97. })
  98. } else if (call.id === 'bash-2') {
  99. expect(toolText).toContain('COUNT=2 CWD=/tmp')
  100. chunks = sseToolCall('editor-create', 'str_replace_editor', {
  101. command: 'create',
  102. path: target,
  103. file_text: 'alpha\n',
  104. })
  105. } else if (call.id === 'editor-create') {
  106. expect(toolText).toContain('New file created successfully')
  107. chunks = sseToolCall('editor-replace', 'str_replace_editor', {
  108. command: 'str_replace',
  109. path: target,
  110. old_str: 'alpha',
  111. new_str: 'beta',
  112. })
  113. } else if (call.id === 'editor-replace') {
  114. expect(toolText).toContain('has been edited successfully')
  115. chunks = sseText('PERSISTENT_TOOLS_OK')
  116. } else {
  117. throw new Error(`unexpected tool call ${call.id}`)
  118. }
  119. }
  120. response.writeHead(200, { 'content-type': 'text/event-stream' })
  121. for (const chunk of chunks) response.write(chunk)
  122. response.end()
  123. })
  124. })
  125. await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
  126. const address = modelServer.address()
  127. if (address === null || typeof address === 'string') throw new Error('model server did not bind')
  128. const launch = resolveExampleLaunch({
  129. srcBin: runtimeBin,
  130. configArgs: [],
  131. tsconfigPath: repoTsconfig,
  132. })
  133. const harness = new DeepSeekHarness({
  134. launch: {
  135. command: launch.command,
  136. args: launch.args,
  137. cwd: repoRoot,
  138. env: {
  139. ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record<string, string>,
  140. ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record<string, string>,
  141. DSH_CORDIS_CONFIG: configPath,
  142. DSH_CWD: cwd,
  143. DSH_SESSION_ROOT: sessionRoot,
  144. DEEPSEEK_API_KEY: 'keyless-local-mock',
  145. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  146. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  147. },
  148. requestTimeoutMs: 60_000,
  149. },
  150. cwd,
  151. provider: 'deepseek',
  152. model: 'deepseek-v4-flash',
  153. })
  154. try {
  155. const result = await harness.run(
  156. 'Prove that bash state persists, then create and edit note.txt.',
  157. { sessionId: 'persistent-tools-snapshot' },
  158. )
  159. const calls = result.events.flatMap((event) => {
  160. if (event.type !== 'tool/call') return []
  161. return [{
  162. name: event.data.name,
  163. arguments: normalize(event.data.arguments, cwd),
  164. }]
  165. })
  166. const results = result.events.flatMap((event) => {
  167. if (event.type !== 'tool/result') return []
  168. return event.data.message.content.flatMap((block) => {
  169. if (block.type !== 'tool-result') return []
  170. return block.content.flatMap(content =>
  171. content.type === 'text'
  172. ? [{ text: normalize(content.text, cwd) }]
  173. : [])
  174. })
  175. })
  176. const tools = (requests[0]?.tools ?? []).map(tool => ({
  177. name: tool.function?.name,
  178. required: tool.function?.parameters?.required ?? [],
  179. })).sort((left, right) => {
  180. const leftName = String(left.name)
  181. const rightName = String(right.name)
  182. return leftName < rightName ? -1 : leftName > rightName ? 1 : 0
  183. })
  184. const behavior = {
  185. tools,
  186. calls,
  187. results,
  188. final: {
  189. status: result.status,
  190. reason: result.reason,
  191. response: result.finalResponse,
  192. file: await readFile(target, 'utf8'),
  193. },
  194. }
  195. if (process.env.DSH_SNAPSHOT === 'refresh') {
  196. await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`)
  197. }
  198. expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8')))
  199. } finally {
  200. await harness.close()
  201. await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
  202. await rm(cwd, { recursive: true, force: true })
  203. }
  204. }, 75_000)
  205. })