tools.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  4. import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
  5. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import TerminalSessionService from '@deepseek-ai/dsh-terminal'
  8. import type {
  9. TerminalBackend,
  10. TerminalBackendSession,
  11. TerminalReadRequest,
  12. TerminalSendOperation,
  13. TerminalSendRequest,
  14. TerminalSessionStatus,
  15. TerminalSignal,
  16. TerminalWaitReason,
  17. } from '@deepseek-ai/dsh-terminal'
  18. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  19. import ToolRuntime 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 session = Session.create(id, [], {
  30. version: SESSION_FORMAT_VERSION,
  31. id,
  32. createdAt: 0,
  33. isSeeded: false,
  34. ...cwd === undefined ? {} : { cwd },
  35. })
  36. const value: Agent = {
  37. id,
  38. options: {},
  39. session,
  40. inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  41. status: 'idle',
  42. ctx: scope.ctx,
  43. send: () => {},
  44. followup: () => {},
  45. steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
  46. inject: () => {},
  47. cancel() {},
  48. runMaintenance: task => task(new AbortController().signal),
  49. whenIdle: () => Promise.resolve(),
  50. }
  51. ctx.agents.register(value)
  52. return value
  53. }
  54. function text(result: { content: { type: string; text?: string }[] }): string {
  55. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  56. }
  57. function call(
  58. ctx: Context,
  59. owner: Agent | undefined,
  60. command: string,
  61. signal = new AbortController().signal,
  62. ) {
  63. return ctx.tools.execute({
  64. signal,
  65. callId: ToolCallId(`persistent-bash-${++callNumber}`),
  66. name: 'bash',
  67. arguments: { command },
  68. ...owner === undefined ? {} : { agent: owner },
  69. })
  70. }
  71. type StubMode =
  72. | 'normal'
  73. | 'prompt-only'
  74. | 'prompt-crlf'
  75. | 'empty-read'
  76. | 'stalled-read'
  77. | 'exit'
  78. | 'signal-exit'
  79. | 'unknown-exit'
  80. | 'wait-for-abort'
  81. | 'end-on-abort'
  82. | 'idle-then-normal'
  83. | 'large'
  84. | 'nonzero'
  85. | 'torn-status'
  86. | 'finish-torn-status'
  87. | 'end-only'
  88. | 'init-exit'
  89. | 'init-timeout'
  90. | 'spawn-error'
  91. | 'send-error'
  92. | 'prompt-after-idle'
  93. | 'incremental-fallback'
  94. | 'empty-page-after-latest'
  95. | 'paged-scrollback'
  96. | 'exit-after-send'
  97. class StubPtySession implements TerminalBackendSession {
  98. readonly motd = 'stub> '
  99. readonly pid = 123
  100. statusValue: TerminalSessionStatus = { kind: 'running' }
  101. scrollback = this.motd
  102. closed: string[] = []
  103. mode: StubMode
  104. sends = 0
  105. pendingText = ''
  106. historyTruncated = false
  107. throwOnSend = false
  108. constructor(mode: StubMode) {
  109. this.mode = mode
  110. }
  111. startSend(request: TerminalSendRequest): TerminalSendOperation {
  112. this.sends += 1
  113. if (request.text.startsWith('stty -echo')) {
  114. if (this.mode === 'init-exit') {
  115. this.statusValue = { kind: 'exited', exitCode: 1, signal: null }
  116. return this.operation(Promise.resolve(this.result('', 'session_exit')))
  117. }
  118. if (this.mode === 'init-timeout') {
  119. return this.operation(Promise.resolve(this.result('', 'timeout')))
  120. }
  121. return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
  122. }
  123. if (this.mode === 'send-error') throw new Error('stub send failed')
  124. if (this.throwOnSend) throw new Error('PTY session has exited')
  125. if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') {
  126. const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
  127. request.signal?.addEventListener('abort', () => {
  128. const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
  129. const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(request.text)?.[0]
  130. const output = this.mode === 'end-on-abort'
  131. ? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}`
  132. : 'partial output'
  133. this.scrollback += output
  134. resolve(this.result(output, 'stdin_read'))
  135. }, { once: true })
  136. })
  137. return this.operation(done)
  138. }
  139. if (this.mode === 'idle-then-normal') {
  140. this.mode = 'normal'
  141. this.pendingText = request.text
  142. return this.operation(Promise.resolve(this.result('', 'inferred_idle')))
  143. }
  144. if (this.mode === 'prompt-after-idle') {
  145. if (request.text.length > 0) {
  146. const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
  147. const output = `${start ?? ''}\npartial syntax output\n`
  148. this.scrollback += output
  149. return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
  150. }
  151. const output = `bash: syntax error\n${this.motd}`
  152. this.scrollback += output
  153. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  154. }
  155. if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
  156. const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
  157. const output = `bash: syntax error${newline}${this.motd}${newline}`
  158. this.scrollback += output
  159. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  160. }
  161. const sent = request.text.length > 0 ? request.text : this.pendingText
  162. this.pendingText = ''
  163. const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0]
  164. const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0]
  165. if (this.mode === 'incremental-fallback') {
  166. const incremental = `${start ?? ''}\nincrement\n${this.motd}`
  167. return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental)
  168. }
  169. if (this.mode === 'exit-after-send') {
  170. // A fast `exit` settles the send while the exit event is still in
  171. // flight; the shell flips to exited before the tool's next poll,
  172. // exactly like the real backend. The tool must re-observe status
  173. // instead of sending.
  174. const output = `${start ?? ''}\n`
  175. this.scrollback += output
  176. const settled = this.result(output, 'inferred_idle')
  177. this.statusValue = { kind: 'exited', exitCode: 9, signal: null }
  178. this.throwOnSend = true
  179. return this.operation(Promise.resolve(settled))
  180. }
  181. if (this.mode === 'torn-status') {
  182. const output = `${start ?? ''}\nhello from stub\n${end ?? ''}`
  183. this.scrollback += output
  184. this.mode = 'finish-torn-status'
  185. return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
  186. }
  187. if (this.mode === 'finish-torn-status') {
  188. const output = `7\n${this.motd}`
  189. this.scrollback += output
  190. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  191. }
  192. if (this.mode === 'end-only') {
  193. const output = `recovered output\n${end ?? ''}0\n${this.motd}`
  194. this.scrollback += output
  195. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  196. }
  197. const commandOutput = this.mode === 'large'
  198. ? 'x'.repeat(100)
  199. : this.mode === 'nonzero' ? '' : 'hello from stub'
  200. const exitCode = this.mode === 'nonzero' ? 7 : 0
  201. const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}`
  202. this.scrollback += output
  203. if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') {
  204. const exitedOutput = `${start ?? ''}\nhello from stub\n`
  205. this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
  206. this.statusValue = this.mode === 'signal-exit'
  207. ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' }
  208. : this.mode === 'exit'
  209. ? { kind: 'exited', exitCode: 9, signal: null }
  210. : { kind: 'exited', exitCode: null, signal: null }
  211. return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
  212. }
  213. return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
  214. }
  215. read(request: TerminalReadRequest) {
  216. if (this.mode === 'empty-read') {
  217. return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
  218. }
  219. if (this.mode === 'stalled-read') {
  220. return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
  221. }
  222. if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) {
  223. return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
  224. }
  225. const lines = this.scrollback.split('\n')
  226. if (this.mode === 'paged-scrollback') {
  227. const offset = request.offset ?? 0
  228. const end = lines.length - offset
  229. const start = Math.max(0, end - 3)
  230. const returnedLines = end - start
  231. return {
  232. text: lines.slice(start, end).join('\n'),
  233. totalLines: lines.length,
  234. lineBegin: offset,
  235. lineEnd: offset + returnedLines,
  236. truncated: this.historyTruncated,
  237. }
  238. }
  239. return {
  240. text: this.scrollback,
  241. totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
  242. lineBegin: 0,
  243. lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
  244. truncated: this.historyTruncated,
  245. }
  246. }
  247. signal(_signal: TerminalSignal) {
  248. return Promise.resolve({ delivered: true as const, targetPgid: 123 })
  249. }
  250. status() {
  251. return this.statusValue
  252. }
  253. async close(reason: string) {
  254. this.closed.push(reason)
  255. this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
  256. }
  257. private result(viewport: string, waitReason: TerminalWaitReason) {
  258. return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false }
  259. }
  260. private operation(done: Promise<ReturnType<StubPtySession['result']>>, delta = ''): TerminalSendOperation {
  261. return {
  262. done,
  263. readOutput: () => ({ delta, truncated: false }),
  264. cancel: () => false,
  265. }
  266. }
  267. }
  268. function stubBackend(initialMode: StubMode = 'normal') {
  269. const sessions: StubPtySession[] = []
  270. const backend: TerminalBackend = {
  271. type: 'stub',
  272. async spawn() {
  273. if (initialMode === 'spawn-error') throw new Error('stub spawn failed')
  274. const session = new StubPtySession(initialMode)
  275. sessions.push(session)
  276. return session
  277. },
  278. }
  279. return { backend, sessions }
  280. }
  281. async function setup(
  282. config: ToolBashPersistent.Config = { backendType: 'stub' },
  283. initialMode: StubMode = 'normal',
  284. ) {
  285. const ctx = new Context()
  286. contexts.push(ctx)
  287. await ctx.plugin(SystemPrompt)
  288. await ctx.plugin(ToolRuntime)
  289. await ctx.plugin(AgentRegistry)
  290. await ctx.plugin(TerminalSessionService)
  291. const stub = stubBackend(initialMode)
  292. ctx.terminals.registerBackend(stub.backend)
  293. const fiber = await ctx.plugin(ToolBashPersistent, config)
  294. return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
  295. }
  296. describe('tool-bash-persistent', () => {
  297. it('registers a configurable schema and reuses one owner shell', async () => {
  298. const { ctx, owner, stub, fiber } = await setup({
  299. backendType: 'stub',
  300. description: 'deployment-specific persistent shell',
  301. })
  302. const schema = ctx.tools.schemas()[0]
  303. expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash'])
  304. expect(schema?.description).toBe('deployment-specific persistent shell')
  305. expect(schema?.parameters).toMatchObject({
  306. required: ['command'],
  307. properties: { command: { type: 'string' } },
  308. })
  309. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' }))
  310. .toEqual({ card: 'terminal', title: 'pwd' })
  311. expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub')
  312. expect(text(await call(ctx, owner, 'echo two'))).toBe('hello from stub')
  313. expect(stub.sessions).toHaveLength(1)
  314. expect(stub.sessions[0]?.sends).toBe(3)
  315. const ownerWithoutCwd = agent(ctx, undefined)
  316. expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
  317. expect(stub.sessions).toHaveLength(2)
  318. await fiber.dispose()
  319. expect(ctx.tools.schemas()).toEqual([])
  320. expect(ctx.tools.get('bash')).toBeUndefined()
  321. })
  322. it('handles inferred idle, stdin_read fallback, shell exit, clipping, and cleanup', async () => {
  323. const { ctx, owner, stub, fiber } = await setup({
  324. backendType: 'stub',
  325. maxOutputChars: 10,
  326. })
  327. await call(ctx, owner, 'warm up')
  328. const session = stub.sessions[0]!
  329. session.mode = 'idle-then-normal'
  330. expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from')
  331. session.mode = 'incremental-fallback'
  332. session.scrollback = ''
  333. expect(text(await call(ctx, owner, 'incremental fallback'))).toContain('increment')
  334. session.mode = 'prompt-only'
  335. const promptFallback = text(await call(ctx, owner, 'bad {'))
  336. expect(promptFallback).toContain('bash: synt')
  337. session.mode = 'prompt-crlf'
  338. session.scrollback = ''
  339. const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
  340. expect(crlfPromptFallback).toContain('bash: synt')
  341. session.mode = 'end-only'
  342. session.scrollback = ''
  343. const missingStart = text(await call(ctx, owner, 'recover marker'))
  344. expect(missingStart).toContain('recovered')
  345. expect(missingStart).toContain('beginning of this command output was dropped')
  346. expect(missingStart).toContain('<response clipped>')
  347. session.mode = 'large'
  348. expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
  349. session.mode = 'nonzero'
  350. expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]')
  351. session.mode = 'exit'
  352. const exited = text(await call(ctx, owner, 'exit'))
  353. expect(exited).toContain('hello from')
  354. expect(exited).toContain('[shell exited: code 9]')
  355. expect(exited).not.toContain('[exit code: 9]')
  356. expect(exited).toContain('next bash call starts from the workspace')
  357. expect(session.closed).toContain('persistent bash shell exited')
  358. await call(ctx, owner, 'new shell')
  359. expect(stub.sessions).toHaveLength(2)
  360. const replacement = stub.sessions[1]!
  361. replacement.mode = 'signal-exit'
  362. expect(text(await call(ctx, owner, 'kill shell')))
  363. .toContain('[shell killed by signal: SIGTERM]')
  364. await call(ctx, owner, 'another shell')
  365. expect(stub.sessions).toHaveLength(3)
  366. const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId
  367. expect(externallyClosed).toBeDefined()
  368. await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup')
  369. await fiber.dispose()
  370. expect(stub.sessions[2]?.closed).toEqual(['external cleanup'])
  371. })
  372. it('waits for status digits after a torn completion marker', async () => {
  373. const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
  374. await call(ctx, owner, 'warm up')
  375. stub.sessions[0]!.mode = 'torn-status'
  376. stub.sessions[0]!.scrollback = ''
  377. expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]')
  378. })
  379. it('reports the exit path when the shell exits between send settlement and the next poll', async () => {
  380. const { ctx, owner, stub } = await setup({ backendType: 'stub' })
  381. await call(ctx, owner, 'warm up')
  382. const session = stub.sessions[0]!
  383. session.mode = 'exit-after-send'
  384. const result = text(await call(ctx, owner, 'exit'))
  385. expect(result).toContain('[shell exited: code 9]')
  386. expect(result).toContain('next bash call starts from the workspace')
  387. expect(session.closed).toContain('persistent bash shell exited')
  388. expect(text(await call(ctx, owner, 'echo "$PWD"'))).toBe('hello from stub')
  389. expect(stub.sessions).toHaveLength(2)
  390. })
  391. it('reports a shell exit when the backend has no code or signal', async () => {
  392. const { ctx, owner, stub } = await setup({ backendType: 'stub' })
  393. await call(ctx, owner, 'warm up')
  394. stub.sessions[0]!.mode = 'unknown-exit'
  395. expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]')
  396. })
  397. it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {
  398. const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
  399. await call(ctx, owner, 'warm up')
  400. const session = stub.sessions[0]!
  401. session.mode = 'end-only'
  402. session.scrollback = ''
  403. expect(text(await call(ctx, owner, 'missing start')))
  404. .toContain('beginning of this command output was dropped')
  405. session.mode = 'empty-read'
  406. expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub')
  407. session.mode = 'stalled-read'
  408. expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
  409. session.mode = 'empty-page-after-latest'
  410. expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
  411. })
  412. it('assembles retained output across backward scrollback pages', async () => {
  413. const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
  414. await call(ctx, owner, 'warm up')
  415. const session = stub.sessions[0]!
  416. session.mode = 'paged-scrollback'
  417. session.scrollback = 'older one\nolder two\nolder three\nolder four\n'
  418. expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
  419. })
  420. it('returns a stdin_read fallback reached after multiple polling rounds', async () => {
  421. const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
  422. await call(ctx, owner, 'warm up')
  423. const session = stub.sessions[0]!
  424. session.mode = 'prompt-after-idle'
  425. session.scrollback = ''
  426. const result = text(await call(ctx, owner, 'bad {'))
  427. expect(result).toContain('partial syntax output')
  428. expect(result).toContain('bash: syntax error')
  429. // The backend owns the prompt text, so the fallback retains it verbatim.
  430. expect(result.endsWith('stub> ')).toBe(true)
  431. expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
  432. })
  433. it('does not attribute old scrollback truncation to a complete current command', async () => {
  434. const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
  435. await call(ctx, owner, 'warm up')
  436. stub.sessions[0]!.historyTruncated = true
  437. const result = text(await call(ctx, owner, 'short command'))
  438. expect(result).toBe('hello from stub')
  439. expect(result).not.toContain('<response clipped>')
  440. expect(result).not.toContain('beginning of this command output was dropped')
  441. })
  442. it('closes a timed-out shell and reports bounded partial output', async () => {
  443. const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
  444. await call(ctx, owner, 'warm up')
  445. stub.sessions[0]!.mode = 'wait-for-abort'
  446. const result = await call(ctx, owner, 'hang')
  447. expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error')
  448. expect(text(result)).toContain('partial output')
  449. expect(text(result)).toContain('next bash call starts from the workspace')
  450. expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out')
  451. })
  452. it.each(['wait-for-abort', 'end-on-abort'] as const)(
  453. 'cancels %s work, resets the shell, and releases a queued call',
  454. async (mode) => {
  455. const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
  456. await call(ctx, owner, 'warm up')
  457. stub.sessions[0]!.mode = mode
  458. const controller = new AbortController()
  459. const cancelled = call(ctx, owner, 'hang', controller.signal)
  460. const queued = call(ctx, owner, 'after cancellation')
  461. setTimeout(() => {
  462. controller.abort(new Error('caller stopped'))
  463. }, 5)
  464. expect((await cancelled).isError).toBe(true)
  465. expect(text(await queued)).toBe('hello from stub')
  466. expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted')
  467. expect(stub.sessions).toHaveLength(2)
  468. },
  469. )
  470. it.each(['init-exit', 'init-timeout'] as const)(
  471. 'fails initialization and closes the unusable shell for %s',
  472. async (mode) => {
  473. const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
  474. expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
  475. expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed')
  476. },
  477. )
  478. it('clears a failed spawn without trying to close an unpublished shell', async () => {
  479. const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error')
  480. expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
  481. expect(stub.sessions).toHaveLength(0)
  482. })
  483. it('resets a cached shell after startSend fails', async () => {
  484. const { ctx, owner, stub } = await setup()
  485. await call(ctx, owner, 'warm up')
  486. stub.sessions[0]!.mode = 'send-error'
  487. expect((await call(ctx, owner, 'fails')).isError).toBe(true)
  488. expect(stub.sessions[0]?.closed).toContain('persistent bash send failed')
  489. expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub')
  490. expect(stub.sessions).toHaveLength(2)
  491. })
  492. it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => {
  493. const ctx = new Context()
  494. contexts.push(ctx)
  495. await ctx.plugin(SystemPrompt)
  496. await ctx.plugin(ToolRuntime)
  497. await ctx.plugin(AgentRegistry)
  498. await ctx.plugin(TerminalSessionService)
  499. const spawnStarted = Promise.withResolvers<undefined>()
  500. const spawnAborted = Promise.withResolvers<undefined>()
  501. ctx.terminals.registerBackend({
  502. type: 'slow',
  503. spawn: spec => new Promise((_resolve, reject) => {
  504. spawnStarted.resolve(undefined)
  505. spec.signal?.addEventListener('abort', () => {
  506. spawnAborted.resolve(undefined)
  507. const reason: unknown = spec.signal?.reason
  508. reject(reason instanceof Error
  509. ? reason
  510. : new Error('slow PTY spawn aborted', { cause: reason }))
  511. }, { once: true })
  512. }),
  513. })
  514. const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' })
  515. const owner = agent(ctx, '/workspace')
  516. const running = call(ctx, owner, 'pwd')
  517. await spawnStarted.promise
  518. await fiber.dispose()
  519. await spawnAborted.promise
  520. expect((await running).isError).toBe(true)
  521. expect(ctx.terminals.list(owner)).toEqual([])
  522. })
  523. it('rejects invalid config and invalid calls', async () => {
  524. const { ctx, owner, stub } = await setup()
  525. expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
  526. expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
  527. const controller = new AbortController()
  528. controller.abort(new Error('caller stopped'))
  529. expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true)
  530. expect(stub.sessions).toHaveLength(0)
  531. expect(() => {
  532. ToolBashPersistent.apply(new Context(), { backendType: '' })
  533. }).toThrow('backendType must be non-empty')
  534. expect(() => {
  535. ToolBashPersistent.apply(new Context(), { timeoutMs: 0 })
  536. }).toThrow('timeoutMs must be a positive safe integer')
  537. expect(() => {
  538. ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 })
  539. }).toThrow('maxOutputChars must be a positive safe integer')
  540. expect(() => {
  541. ToolBashPersistent.apply(new Context(), { description: ' ' })
  542. }).toThrow('description must be non-empty')
  543. })
  544. })