tools.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  5. import AgentRegistry from '@deepseek-ai/dsh-agent'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import PtyService from '@deepseek-ai/dsh-pty'
  8. import type {
  9. PtyBackend,
  10. PtyBackendSession,
  11. PtyReadRequest,
  12. PtySendOperation,
  13. PtySendRequest,
  14. PtySessionStatus,
  15. PtySignal,
  16. PtyWaitReason,
  17. } from '@deepseek-ai/dsh-pty'
  18. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  19. import ToolRegistry from '@deepseek-ai/dsh-tools'
  20. import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
  21. const contexts: Context[] = []
  22. let callNumber = 0
  23. afterEach(async () => {
  24. for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
  25. })
  26. function agent(ctx: Context, cwd: string | undefined): Agent {
  27. const id = SessionId(`persistent-bash-owner-${callNumber}`)
  28. const scope = ctx.plugin(() => {})
  29. const value: Agent = {
  30. id,
  31. options: {},
  32. session: new Session(id, [], {
  33. version: 0,
  34. id,
  35. createdAt: 0,
  36. ...cwd === undefined ? {} : { cwd },
  37. }),
  38. status: 'idle',
  39. acceptsNextStep: false,
  40. ctx: scope.ctx,
  41. followup: () => {},
  42. steer: () => {},
  43. inject: () => {},
  44. send: () => {},
  45. cancel() {},
  46. whenIdle: () => Promise.resolve(),
  47. }
  48. ctx.agents.register(value)
  49. return value
  50. }
  51. function text(result: { content: { type: string; text?: string }[] }): string {
  52. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  53. }
  54. function call(
  55. ctx: Context,
  56. owner: Agent | undefined,
  57. command: string,
  58. signal = new AbortController().signal,
  59. ) {
  60. return ctx.tools.execute({
  61. signal,
  62. callId: CallId(`persistent-bash-${++callNumber}`),
  63. name: 'bash',
  64. arguments: { command },
  65. ...owner === undefined ? {} : { agent: owner },
  66. })
  67. }
  68. type StubMode =
  69. | 'normal'
  70. | 'prompt-only'
  71. | 'prompt-crlf'
  72. | 'empty-read'
  73. | 'stalled-read'
  74. | 'exit'
  75. | 'wait-for-abort'
  76. | 'idle-then-normal'
  77. | 'large'
  78. | 'end-only'
  79. | 'init-exit'
  80. | 'init-timeout'
  81. | 'spawn-error'
  82. | 'send-error'
  83. | 'prompt-after-idle'
  84. | 'empty-page-after-latest'
  85. class StubPtySession implements PtyBackendSession {
  86. readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
  87. readonly pid = 123
  88. statusValue: PtySessionStatus = { kind: 'running' }
  89. scrollback = this.motd
  90. closed: string[] = []
  91. mode: StubMode
  92. sends = 0
  93. pendingText = ''
  94. historyTruncated = false
  95. constructor(mode: StubMode) {
  96. this.mode = mode
  97. }
  98. startSend(request: PtySendRequest): PtySendOperation {
  99. this.sends += 1
  100. if (request.text.startsWith('stty -echo')) {
  101. if (this.mode === 'init-exit') {
  102. this.statusValue = { kind: 'exited', exitCode: 1, signal: null }
  103. return this.operation(Promise.resolve(this.result('', 'session_exit')))
  104. }
  105. if (this.mode === 'init-timeout') {
  106. return this.operation(Promise.resolve(this.result('', 'timeout')))
  107. }
  108. return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
  109. }
  110. if (this.mode === 'send-error') throw new Error('stub send failed')
  111. if (this.mode === 'wait-for-abort') {
  112. const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
  113. request.signal?.addEventListener('abort', () => {
  114. this.scrollback += 'partial output'
  115. resolve(this.result('partial output', 'stdin_read'))
  116. }, { once: true })
  117. })
  118. return this.operation(done)
  119. }
  120. if (this.mode === 'idle-then-normal') {
  121. this.mode = 'normal'
  122. this.pendingText = request.text
  123. return this.operation(Promise.resolve(this.result('', 'inferred_idle')))
  124. }
  125. if (this.mode === 'prompt-after-idle') {
  126. if (request.text.length > 0) {
  127. const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
  128. const output = `${start ?? ''}\npartial syntax output\n`
  129. this.scrollback += output
  130. return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
  131. }
  132. const output = `bash: syntax error\n${this.motd}`
  133. this.scrollback += output
  134. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  135. }
  136. if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
  137. const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
  138. const output = `bash: syntax error${newline}${this.motd}${newline}`
  139. this.scrollback += output
  140. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  141. }
  142. const sent = request.text.length > 0 ? request.text : this.pendingText
  143. this.pendingText = ''
  144. const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0]
  145. const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0]
  146. if (this.mode === 'end-only') {
  147. const output = `recovered output\n${end ?? ''}0\n${this.motd}`
  148. this.scrollback += output
  149. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  150. }
  151. const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub'
  152. const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}`
  153. this.scrollback += output
  154. if (this.mode === 'exit') {
  155. const exitedOutput = `${start ?? ''}\nhello from stub\n`
  156. this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
  157. this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
  158. return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
  159. }
  160. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  161. }
  162. read(request: PtyReadRequest) {
  163. if (this.mode === 'empty-read') {
  164. return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
  165. }
  166. if (this.mode === 'stalled-read') {
  167. return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
  168. }
  169. if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) {
  170. return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
  171. }
  172. const lines = this.scrollback.split('\n')
  173. return {
  174. text: this.scrollback,
  175. totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
  176. lineBegin: 0,
  177. lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
  178. truncated: this.historyTruncated,
  179. }
  180. }
  181. signal(_signal: PtySignal) {
  182. return Promise.resolve({ delivered: true as const, targetPgid: 123 })
  183. }
  184. status() {
  185. return this.statusValue
  186. }
  187. async close(reason: string) {
  188. this.closed.push(reason)
  189. this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
  190. }
  191. private result(viewport: string, waitReason: PtyWaitReason) {
  192. return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false }
  193. }
  194. private operation(done: Promise<ReturnType<StubPtySession['result']>>): PtySendOperation {
  195. return {
  196. done,
  197. readOutput: () => ({ delta: '', truncated: false }),
  198. cancel: () => false,
  199. }
  200. }
  201. }
  202. function stubBackend(initialMode: StubMode = 'normal') {
  203. const sessions: StubPtySession[] = []
  204. const backend: PtyBackend = {
  205. type: 'stub',
  206. async spawn() {
  207. if (initialMode === 'spawn-error') throw new Error('stub spawn failed')
  208. const session = new StubPtySession(initialMode)
  209. sessions.push(session)
  210. return session
  211. },
  212. }
  213. return { backend, sessions }
  214. }
  215. async function setup(
  216. config: ToolBashPersistent.Config = { backendType: 'stub' },
  217. initialMode: StubMode = 'normal',
  218. ) {
  219. const ctx = new Context()
  220. contexts.push(ctx)
  221. await ctx.plugin(SystemPrompt)
  222. await ctx.plugin(ToolRegistry)
  223. await ctx.plugin(AgentRegistry)
  224. await ctx.plugin(PtyService)
  225. const stub = stubBackend(initialMode)
  226. ctx.pty.registerBackend(stub.backend)
  227. const fiber = await ctx.plugin(ToolBashPersistent, config)
  228. return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
  229. }
  230. describe('tool-bash-persistent', () => {
  231. it('registers a configurable schema and reuses one owner shell', async () => {
  232. const { ctx, owner, stub } = await setup({
  233. backendType: 'stub',
  234. description: 'deployment-specific persistent shell',
  235. })
  236. const schema = ctx.tools.schemas()[0]
  237. expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash'])
  238. expect(schema?.description).toBe('deployment-specific persistent shell')
  239. expect(schema?.parameters).toMatchObject({
  240. required: ['command'],
  241. properties: { command: { type: 'string' } },
  242. })
  243. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' }))
  244. .toEqual({ card: 'terminal', title: 'pwd' })
  245. expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub')
  246. expect(text(await call(ctx, owner, 'echo two'))).toBe('hello from stub')
  247. expect(stub.sessions).toHaveLength(1)
  248. expect(stub.sessions[0]?.sends).toBe(3)
  249. const ownerWithoutCwd = agent(ctx, undefined)
  250. expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
  251. expect(stub.sessions).toHaveLength(2)
  252. })
  253. it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
  254. const { ctx, owner, stub, fiber } = await setup({
  255. backendType: 'stub',
  256. maxOutputChars: 10,
  257. })
  258. await call(ctx, owner, 'warm up')
  259. const session = stub.sessions[0]!
  260. session.mode = 'idle-then-normal'
  261. expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from')
  262. session.mode = 'prompt-only'
  263. const promptFallback = text(await call(ctx, owner, 'bad {'))
  264. expect(promptFallback).toContain('bash: synt')
  265. expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
  266. session.mode = 'prompt-crlf'
  267. session.scrollback = ''
  268. const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
  269. expect(crlfPromptFallback).toContain('bash: synt')
  270. expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
  271. session.mode = 'end-only'
  272. session.scrollback = ''
  273. const missingStart = text(await call(ctx, owner, 'recover marker'))
  274. expect(missingStart).toContain('recovered')
  275. expect(missingStart).toContain('beginning of this command output was dropped')
  276. expect(missingStart).toContain('<response clipped>')
  277. session.mode = 'large'
  278. expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
  279. session.mode = 'exit'
  280. const exited = text(await call(ctx, owner, 'exit'))
  281. expect(exited).toContain('hello from')
  282. expect(exited).toContain('next bash call starts from the workspace')
  283. expect(session.closed).toContain('persistent bash shell exited')
  284. await call(ctx, owner, 'new shell')
  285. expect(stub.sessions).toHaveLength(2)
  286. const externallyClosed = ctx.pty.list(owner)[0]?.sessionId
  287. expect(externallyClosed).toBeDefined()
  288. await ctx.pty.kill(owner, externallyClosed!, 'external cleanup')
  289. await fiber.dispose()
  290. expect(stub.sessions[1]?.closed).toEqual(['external cleanup'])
  291. })
  292. it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {
  293. const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
  294. await call(ctx, owner, 'warm up')
  295. const session = stub.sessions[0]!
  296. session.mode = 'end-only'
  297. session.scrollback = ''
  298. expect(text(await call(ctx, owner, 'missing start')))
  299. .toContain('beginning of this command output was dropped')
  300. session.mode = 'empty-read'
  301. expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub')
  302. session.mode = 'stalled-read'
  303. expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
  304. session.mode = 'empty-page-after-latest'
  305. expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
  306. })
  307. it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
  308. const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
  309. await call(ctx, owner, 'warm up')
  310. const session = stub.sessions[0]!
  311. session.mode = 'prompt-after-idle'
  312. session.scrollback = ''
  313. const result = text(await call(ctx, owner, 'bad {'))
  314. expect(result).toContain('partial syntax output')
  315. expect(result).toContain('bash: syntax error')
  316. expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
  317. expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
  318. })
  319. it('does not attribute old scrollback truncation to a complete current command', async () => {
  320. const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
  321. await call(ctx, owner, 'warm up')
  322. stub.sessions[0]!.historyTruncated = true
  323. const result = text(await call(ctx, owner, 'short command'))
  324. expect(result).toBe('hello from stub')
  325. expect(result).not.toContain('<response clipped>')
  326. expect(result).not.toContain('beginning of this command output was dropped')
  327. })
  328. it('closes a timed-out shell and reports bounded partial output', async () => {
  329. const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
  330. await call(ctx, owner, 'warm up')
  331. stub.sessions[0]!.mode = 'wait-for-abort'
  332. const result = await call(ctx, owner, 'hang')
  333. expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error')
  334. expect(text(result)).toContain('partial output')
  335. expect(text(result)).toContain('next bash call starts from the workspace')
  336. expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out')
  337. })
  338. it('cancels in-flight work, resets the shell, and releases a queued call', async () => {
  339. const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
  340. await call(ctx, owner, 'warm up')
  341. stub.sessions[0]!.mode = 'wait-for-abort'
  342. const controller = new AbortController()
  343. const cancelled = call(ctx, owner, 'hang', controller.signal)
  344. const queued = call(ctx, owner, 'after cancellation')
  345. setTimeout(() => {
  346. controller.abort(new Error('caller stopped'))
  347. }, 5)
  348. expect((await cancelled).isError).toBe(true)
  349. expect(text(await queued)).toBe('hello from stub')
  350. expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted')
  351. expect(stub.sessions).toHaveLength(2)
  352. })
  353. it.each(['init-exit', 'init-timeout'] as const)(
  354. 'fails initialization and closes the unusable shell for %s',
  355. async (mode) => {
  356. const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
  357. expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
  358. expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed')
  359. },
  360. )
  361. it('clears a failed spawn without trying to close an unpublished shell', async () => {
  362. const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error')
  363. expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
  364. expect(stub.sessions).toHaveLength(0)
  365. })
  366. it('resets a cached shell after startSend fails', async () => {
  367. const { ctx, owner, stub } = await setup()
  368. await call(ctx, owner, 'warm up')
  369. stub.sessions[0]!.mode = 'send-error'
  370. expect((await call(ctx, owner, 'fails')).isError).toBe(true)
  371. expect(stub.sessions[0]?.closed).toContain('persistent bash send failed')
  372. expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub')
  373. expect(stub.sessions).toHaveLength(2)
  374. })
  375. it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => {
  376. const ctx = new Context()
  377. contexts.push(ctx)
  378. await ctx.plugin(SystemPrompt)
  379. await ctx.plugin(ToolRegistry)
  380. await ctx.plugin(AgentRegistry)
  381. await ctx.plugin(PtyService)
  382. const spawnStarted = Promise.withResolvers<undefined>()
  383. const spawnAborted = Promise.withResolvers<undefined>()
  384. ctx.pty.registerBackend({
  385. type: 'slow',
  386. spawn: spec => new Promise((_resolve, reject) => {
  387. spawnStarted.resolve(undefined)
  388. spec.signal?.addEventListener('abort', () => {
  389. spawnAborted.resolve(undefined)
  390. const reason: unknown = spec.signal?.reason
  391. reject(reason instanceof Error
  392. ? reason
  393. : new Error('slow PTY spawn aborted', { cause: reason }))
  394. }, { once: true })
  395. }),
  396. })
  397. const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' })
  398. const owner = agent(ctx, '/workspace')
  399. const running = call(ctx, owner, 'pwd')
  400. await spawnStarted.promise
  401. await fiber.dispose()
  402. await spawnAborted.promise
  403. expect((await running).isError).toBe(true)
  404. expect(ctx.pty.list(owner)).toEqual([])
  405. })
  406. it('rejects invalid config and invalid calls', async () => {
  407. const { ctx, owner, stub } = await setup()
  408. expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
  409. expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
  410. const controller = new AbortController()
  411. controller.abort(new Error('caller stopped'))
  412. expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true)
  413. expect(stub.sessions).toHaveLength(0)
  414. expect(() => {
  415. ToolBashPersistent.apply(new Context(), { backendType: '' })
  416. }).toThrow('backendType must be non-empty')
  417. expect(() => {
  418. ToolBashPersistent.apply(new Context(), { timeoutMs: 0 })
  419. }).toThrow('timeoutMs must be a positive safe integer')
  420. expect(() => {
  421. ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 })
  422. }).toThrow('maxOutputChars must be a positive safe integer')
  423. expect(() => {
  424. ToolBashPersistent.apply(new Context(), { description: ' ' })
  425. }).toThrow('description must be non-empty')
  426. })
  427. })