tools.spec.ts 25 KB

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