tools.spec.ts 19 KB

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