create.snapshot.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. featureId,
  4. createPackageManager,
  5. type NestedMultiSelectValue,
  6. type PromptPort,
  7. } from '@deepseek-ai/dsh-helper'
  8. import type {
  9. ConfirmPromptRequest,
  10. MultiSelectPromptRequest,
  11. NestedMultiSelectRequest,
  12. PromptOutcome,
  13. SecretPromptRequest,
  14. SelectPromptRequest,
  15. TextPromptRequest,
  16. } from '../../helper/src/questions/prompt-port.ts'
  17. import { parseCreateArgs } from '../src/args.ts'
  18. import { CreateWizard } from '../src/create-wizard.ts'
  19. import { CREATE_TEMPLATES, packageManagerTemplateModel } from '../src/templates/create-templates.ts'
  20. class RecordingPort implements PromptPort {
  21. readonly transcript: unknown[] = []
  22. readonly #answers: unknown[]
  23. constructor(answers: unknown[]) { this.#answers = [...answers] }
  24. answer<T>(record: unknown): Promise<PromptOutcome<T>> {
  25. this.transcript.push(record)
  26. return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T })
  27. }
  28. text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
  29. return this.answer({
  30. kind: 'text',
  31. message: request.message,
  32. defaultValue: request.defaultValue,
  33. initialValue: request.initialValue,
  34. })
  35. }
  36. secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
  37. return this.answer({ kind: 'secret', message: request.message })
  38. }
  39. select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
  40. return this.answer({
  41. kind: 'select', message: request.message, options: request.options.map(option => option.label),
  42. initialValue: request.initialValue,
  43. })
  44. }
  45. multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
  46. return this.answer({
  47. kind: 'multiselect', message: request.message, options: request.options.map(option => option.label),
  48. initialValues: request.initialValues,
  49. })
  50. }
  51. confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
  52. return this.answer({ kind: 'confirm', message: request.message, initialValue: request.initialValue })
  53. }
  54. nestedMultiselect<TValue, TChoice>(
  55. request: NestedMultiSelectRequest<TValue, TChoice>,
  56. ): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
  57. return this.answer({
  58. kind: 'nested-multiselect',
  59. message: request.message,
  60. options: request.options.map(option => ({
  61. label: option.label,
  62. required: option.required,
  63. default: option.default,
  64. choices: option.choices?.map(choice => choice.label),
  65. })),
  66. })
  67. }
  68. }
  69. describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => {
  70. it('renders package-manager-specific setup commands', () => {
  71. const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0'))
  72. expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n')
  73. expect(CREATE_TEMPLATES.setupFailure.render({
  74. directory: '/workspace/agent',
  75. error: 'offline',
  76. ...model,
  77. })).toContain('yarn install && yarn build')
  78. })
  79. it('pins the full unresolved question order and completion messages', async () => {
  80. const port = new RecordingPort([
  81. 'my-agent',
  82. 'my-agent',
  83. 'Snapshot agent',
  84. 'deepseek',
  85. 'secret-key',
  86. 'acp',
  87. [
  88. { value: featureId('persistence'), choices: ['jsonl'] },
  89. { value: featureId('hmr'), choices: [] },
  90. { value: featureId('web'), choices: ['exa'] },
  91. { value: featureId('workflow'), choices: [] },
  92. ],
  93. true,
  94. 'exa-key',
  95. 'none',
  96. 'npm',
  97. false,
  98. ])
  99. const resolved = await new CreateWizard({
  100. args: parseCreateArgs([]),
  101. port,
  102. cwd: '/workspace',
  103. releaseVersion: '0.0.1',
  104. userAgent: '',
  105. versionProbe: async () => '10.0.0',
  106. }).run()
  107. expect({
  108. prompts: port.transcript,
  109. result: {
  110. directory: resolved.directory,
  111. name: resolved.request.name,
  112. manager: resolved.request.packageManager.name,
  113. install: resolved.install,
  114. features: resolved.request.features.map(item => ({ id: item.id, options: item.options })),
  115. },
  116. messages: {
  117. created: CREATE_TEMPLATES.created.render({
  118. name: resolved.request.name,
  119. directory: resolved.directory,
  120. }),
  121. next: CREATE_TEMPLATES.nextSteps.render({
  122. directory: resolved.directory,
  123. setupRequired: false,
  124. ...packageManagerTemplateModel(resolved.request.packageManager),
  125. }),
  126. failure: CREATE_TEMPLATES.setupFailure.render({
  127. directory: resolved.directory,
  128. error: String(new Error('offline')),
  129. ...packageManagerTemplateModel(resolved.request.packageManager),
  130. }),
  131. },
  132. }).toMatchInlineSnapshot(`
  133. {
  134. "messages": {
  135. "created": "Created my-agent in /workspace/my-agent
  136. ",
  137. "failure": "Project files are ready, but setup failed: Error: offline
  138. Retry: cd /workspace/my-agent && npm install && npm run build
  139. ",
  140. "next": "Next: cd /workspace/my-agent && npm start
  141. ",
  142. },
  143. "prompts": [
  144. {
  145. "defaultValue": "my-agent",
  146. "initialValue": undefined,
  147. "kind": "text",
  148. "message": "Where should the project be created?",
  149. },
  150. {
  151. "defaultValue": "my-agent",
  152. "initialValue": undefined,
  153. "kind": "text",
  154. "message": "Package name",
  155. },
  156. {
  157. "defaultValue": "A DeepSeek Harness agent named my-agent",
  158. "initialValue": undefined,
  159. "kind": "text",
  160. "message": "Project description",
  161. },
  162. {
  163. "initialValue": "deepseek",
  164. "kind": "select",
  165. "message": "Model provider",
  166. "options": [
  167. "DeepSeek",
  168. "Custom endpoint (pi-ai)",
  169. ],
  170. },
  171. {
  172. "kind": "secret",
  173. "message": "DeepSeek API key",
  174. },
  175. {
  176. "initialValue": "tui",
  177. "kind": "select",
  178. "message": "Run interface",
  179. "options": [
  180. "ACP server",
  181. "Terminal TUI",
  182. "Embedded context",
  183. ],
  184. },
  185. {
  186. "kind": "nested-multiselect",
  187. "message": "Select features",
  188. "options": [
  189. {
  190. "choices": [
  191. "Local executor",
  192. "Sandboxed executor",
  193. ],
  194. "default": true,
  195. "label": "Command execution",
  196. "required": true,
  197. },
  198. {
  199. "choices": [
  200. "JSONL files",
  201. "SQLite database",
  202. ],
  203. "default": true,
  204. "label": "Durable session storage",
  205. "required": true,
  206. },
  207. {
  208. "choices": undefined,
  209. "default": true,
  210. "label": "Hot-module reload",
  211. "required": false,
  212. },
  213. {
  214. "choices": undefined,
  215. "default": true,
  216. "label": "Read, write, and edit local files",
  217. "required": false,
  218. },
  219. {
  220. "choices": undefined,
  221. "default": true,
  222. "label": "Model-facing task tracking",
  223. "required": false,
  224. },
  225. {
  226. "choices": undefined,
  227. "default": true,
  228. "label": "Local skill discovery",
  229. "required": false,
  230. },
  231. {
  232. "choices": [
  233. "DeepSeek search",
  234. "Exa search",
  235. "Perplexity search",
  236. "Fetch only",
  237. ],
  238. "default": false,
  239. "label": "Web search and fetch tools",
  240. "required": false,
  241. },
  242. {
  243. "choices": [
  244. "Fresh child agent",
  245. "Fork parent history",
  246. ],
  247. "default": false,
  248. "label": "Delegate work to child agents",
  249. "required": false,
  250. },
  251. {
  252. "choices": undefined,
  253. "default": false,
  254. "label": "Scripted multi-agent workflows",
  255. "required": false,
  256. },
  257. {
  258. "choices": undefined,
  259. "default": false,
  260. "label": "Automatic context compaction",
  261. "required": false,
  262. },
  263. {
  264. "choices": [
  265. "Claude Code hooks",
  266. "Codex hooks",
  267. ],
  268. "default": false,
  269. "label": "Run Claude Code or Codex hooks",
  270. "required": false,
  271. },
  272. {
  273. "choices": undefined,
  274. "default": false,
  275. "label": "Loop-hygiene reminders",
  276. "required": false,
  277. },
  278. {
  279. "choices": undefined,
  280. "default": false,
  281. "label": "Tool timeout policy",
  282. "required": false,
  283. },
  284. {
  285. "choices": undefined,
  286. "default": false,
  287. "label": "Ask the user from the model loop",
  288. "required": false,
  289. },
  290. ],
  291. },
  292. {
  293. "initialValue": true,
  294. "kind": "confirm",
  295. "message": "Add the recommended tool timeout policy for web search and fetch tools?",
  296. },
  297. {
  298. "kind": "secret",
  299. "message": "Exa API key",
  300. },
  301. {
  302. "initialValue": "none",
  303. "kind": "select",
  304. "message": "Local plugin",
  305. "options": [
  306. "No local plugin",
  307. "Cordis plugin",
  308. "Model-facing tool",
  309. ],
  310. },
  311. {
  312. "initialValue": "npm",
  313. "kind": "select",
  314. "message": "Package manager",
  315. "options": [
  316. "npm",
  317. "pnpm",
  318. "Yarn",
  319. ],
  320. },
  321. {
  322. "initialValue": true,
  323. "kind": "confirm",
  324. "message": "Run npm install and then build the project?",
  325. },
  326. ],
  327. "result": {
  328. "directory": "/workspace/my-agent",
  329. "features": [
  330. {
  331. "id": "provider",
  332. "options": [
  333. "deepseek",
  334. ],
  335. },
  336. {
  337. "id": "spine",
  338. "options": [
  339. "default",
  340. ],
  341. },
  342. {
  343. "id": "app",
  344. "options": [
  345. "acp",
  346. ],
  347. },
  348. {
  349. "id": "bash",
  350. "options": [
  351. "local",
  352. ],
  353. },
  354. {
  355. "id": "persistence",
  356. "options": [
  357. "jsonl",
  358. ],
  359. },
  360. {
  361. "id": "hmr",
  362. "options": [
  363. "default",
  364. ],
  365. },
  366. {
  367. "id": "web",
  368. "options": [
  369. "exa",
  370. ],
  371. },
  372. {
  373. "id": "workflow",
  374. "options": [
  375. "workerthread",
  376. ],
  377. },
  378. {
  379. "id": "timeout-policy",
  380. "options": [
  381. "default",
  382. ],
  383. },
  384. ],
  385. "install": false,
  386. "manager": "npm",
  387. "name": "my-agent",
  388. },
  389. }
  390. `)
  391. })
  392. })