tools.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. /**
  2. * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor,
  3. * exercised through `ctx.tools.execute()` so nothing bypasses the tool
  4. * registry. The fake executor makes every seam outcome scriptable — output
  5. * text, truncation, timeout, abort, nonzero exits, background handles — so
  6. * these tests verify the schema, argument validation, workdir derivation,
  7. * managed `DSH_*` collection, abort translation, canonical result projection,
  8. * rendering, background task wiring, and the UI presenters. Real-pwsh behavior
  9. * is pinned separately in integration.spec.ts.
  10. */
  11. import { describe, expect, it } from 'vitest'
  12. import { Context } from 'cordis'
  13. import { mkdtempSync } from 'node:fs'
  14. import { tmpdir } from 'node:os'
  15. import { join, resolve as resolvePath } from 'node:path'
  16. import { CallId } from '@deepseek-ai/dsh-llm'
  17. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  18. import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
  19. import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
  20. import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
  21. import AgentRegistry from '@deepseek-ai/dsh-agent'
  22. import type { Agent } from '@deepseek-ai/dsh-agent'
  23. import { SessionId } from '@deepseek-ai/dsh-session'
  24. import { BashExecutor } from '@deepseek-ai/dsh-bash'
  25. import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
  26. import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
  27. import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
  28. import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
  29. import { processOutcome } from '../src/background.ts'
  30. import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts'
  31. const testToolSignal = new AbortController().signal
  32. /**
  33. * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
  34. * returns the armed foreground script, `start()` returns the armed background
  35. * handle.
  36. */
  37. class FakeBash extends BashExecutor {
  38. requests: BashExecRequest[] = []
  39. specs: BashExecSpec[] = []
  40. startCalls = 0
  41. handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
  42. backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n')
  43. override resolve(request: BashExecRequest): BashExecSpec {
  44. this.requests.push(request)
  45. return {
  46. command: request.command,
  47. workdir: request.workdir ?? process.cwd(),
  48. timeoutMs: request.timeoutMs ?? 60_000,
  49. stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
  50. ...request.signal ? { signal: request.signal } : {},
  51. ...request.stdin !== undefined ? { stdin: request.stdin } : {},
  52. ...request.env !== undefined ? { env: request.env } : {},
  53. ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
  54. sandboxPolicy: request.sandboxPolicy,
  55. }
  56. }
  57. override async run(spec: BashExecSpec): Promise<BashRunResult> {
  58. this.specs.push(spec)
  59. return this.handler(spec)
  60. }
  61. override start(spec: BashExecSpec): BashProcess {
  62. this.startCalls++
  63. this.specs.push(spec)
  64. return this.backgroundHandler(spec)
  65. }
  66. }
  67. /** A successful run result over the given stdout; overrides script the failure shapes. */
  68. function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
  69. return {
  70. exitCode: 0,
  71. signal: null,
  72. timedOut: false,
  73. aborted: false,
  74. timeoutMs: 60_000,
  75. stdout: { text: stdout, truncated: false },
  76. stderr: { text: '', truncated: false },
  77. ...overrides,
  78. }
  79. }
  80. /** A settled successful background handle; overrides script failure shapes. */
  81. function fakeProcess(delta = 'bg-ok\n'): BashProcess {
  82. let consumed = false
  83. return {
  84. status: 'completed',
  85. exitCode: 0,
  86. signal: null,
  87. done: Promise.resolve(),
  88. readOutput: () => {
  89. if (consumed) return { delta: '', lossy: false }
  90. consumed = true
  91. return { delta, lossy: false }
  92. },
  93. kill: () => false,
  94. }
  95. }
  96. /** A running background handle whose kill() settles it as killed (like a real task_kill). */
  97. function killableProcess(): BashProcess {
  98. let resolveDone: () => void = () => {}
  99. const done = new Promise<void>((resolve) => { resolveDone = resolve })
  100. const proc: BashProcess = {
  101. status: 'running',
  102. exitCode: null,
  103. signal: null,
  104. done,
  105. readOutput: () => ({ delta: '', lossy: false }),
  106. kill: () => {
  107. if (proc.status !== 'running') return false
  108. proc.status = 'killed'
  109. proc.signal = 'SIGTERM'
  110. resolveDone()
  111. return true
  112. },
  113. }
  114. return proc
  115. }
  116. async function setup(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
  117. const ctx = new Context()
  118. await ctx.plugin(SystemPrompt)
  119. await ctx.plugin(ToolRegistry)
  120. await ctx.plugin(AgentRegistry)
  121. await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
  122. await ctx.plugin(FakeBash)
  123. await ctx.plugin(ToolPwsh, toolConfig)
  124. const bash = ctx.bash as FakeBash
  125. return { ctx, bash }
  126. }
  127. /** Full harness: the generic task runtime + its control surface, then the pwsh tool. */
  128. async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
  129. const ctx = new Context()
  130. await ctx.plugin(SystemPrompt)
  131. await ctx.plugin(ToolRegistry)
  132. await ctx.plugin(AgentRegistry)
  133. await ctx.plugin(LocalTaskService)
  134. await ctx.plugin(ToolTasks)
  135. await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
  136. await ctx.plugin(FakeBash)
  137. await ctx.plugin(ToolPwsh, toolConfig)
  138. const bash = ctx.bash as FakeBash
  139. return { ctx, bash }
  140. }
  141. /**
  142. * Build a fake {@link Agent} with the shared agent/session identity, give it a
  143. * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
  144. */
  145. function registerFakeAgent(ctx: Context, sessionId: string): Agent {
  146. const scopeFiber = ctx.plugin(() => {})
  147. const id = SessionId(sessionId)
  148. const agent = {
  149. id,
  150. ctx: scopeFiber.ctx,
  151. session: { id, header: { version: 0, id, createdAt: 0 } },
  152. } as unknown as Agent
  153. ctx.agents.register(agent)
  154. return agent
  155. }
  156. let callCounter = 0
  157. function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
  158. return ctx.tools.execute({
  159. signal: testToolSignal,
  160. callId: CallId(`call-${++callCounter}`),
  161. name,
  162. arguments: args,
  163. ...agent ? { agent } : {},
  164. })
  165. }
  166. function text(result: { content: { type: string; text?: string }[] }): string {
  167. return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
  168. }
  169. async function callUntilText(
  170. ctx: Context,
  171. name: string,
  172. args: unknown,
  173. expected: string,
  174. timeoutMs = 5_000,
  175. ): Promise<Awaited<ReturnType<typeof call>>> {
  176. const deadline = Date.now() + timeoutMs
  177. let last: Awaited<ReturnType<typeof call>> | undefined
  178. while (Date.now() < deadline) {
  179. last = await call(ctx, name, args)
  180. if (text(last).includes(expected)) return last
  181. await new Promise(resolve => setTimeout(resolve, 20))
  182. }
  183. throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`)
  184. }
  185. describe('registration', () => {
  186. it('registers the pwsh tool with its prompt section and schema', async () => {
  187. const { ctx } = await setup()
  188. const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')
  189. expect(schema).toBeDefined()
  190. expect(schema?.description).toContain('PowerShell command')
  191. expect(schema?.parameters.properties).toMatchObject({
  192. command: { type: 'string' },
  193. description: { type: 'string' },
  194. timeoutMs: { type: 'number' },
  195. workdir: { type: 'string' },
  196. run_in_background: { type: 'boolean' },
  197. })
  198. expect(schema?.parameters.required).toEqual(['command', 'description'])
  199. const prompt = renderPrompt(await ctx.systemPrompt.assemble())
  200. expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers')
  201. expect(prompt).toContain('without a signal marker')
  202. })
  203. it('stays pending until ctx.bash exists (inject)', async () => {
  204. const ctx = new Context()
  205. await ctx.plugin(SystemPrompt)
  206. await ctx.plugin(ToolRegistry)
  207. await ctx.plugin(ToolPwsh)
  208. expect(ctx.tools.schemas()).toHaveLength(0)
  209. })
  210. it('unregisters everything on fiber disposal (HMR safety)', async () => {
  211. const ctx = new Context()
  212. await ctx.plugin(SystemPrompt)
  213. await ctx.plugin(ToolRegistry)
  214. await ctx.plugin(BashEnvPlugin)
  215. await ctx.plugin(FakeBash)
  216. const fiber = await ctx.plugin(ToolPwsh)
  217. expect(ctx.tools.schemas()).toHaveLength(1)
  218. await fiber.dispose()
  219. expect(ctx.tools.schemas()).toHaveLength(0)
  220. })
  221. })
  222. describe('argument validation', () => {
  223. it('rejects a blank command or description and a non-positive timeoutMs', async () => {
  224. const { ctx } = await setup()
  225. expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string')
  226. expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string')
  227. expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 })))
  228. .toContain('invalid timeoutMs: expected a positive number')
  229. })
  230. })
  231. describe('execution through the bash seam', () => {
  232. it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => {
  233. const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-'))
  234. const { ctx, bash } = await setup({}, dshHome)
  235. bash.handler = () => runResult('hi\n')
  236. const agent = registerFakeAgent(ctx, 'session-1')
  237. Object.assign(agent.session.header, { cwd: '/sessions/s1' })
  238. const result = await call(ctx, 'pwsh', {
  239. command: 'Write-Output hi',
  240. description: 'say hi',
  241. timeoutMs: 1234,
  242. }, agent)
  243. expect(result.isError).toBe(false)
  244. const request = bash.requests[0]
  245. expect(request?.command).toBe('Write-Output hi')
  246. expect(request?.workdir).toBe('/sessions/s1')
  247. expect(request?.timeoutMs).toBe(1234)
  248. expect(request?.dshEnv).toEqual({
  249. DSH_HOME: dshHome,
  250. DSH_SHELL: '1',
  251. DSH_SESSION_ID: 'session-1',
  252. })
  253. expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
  254. })
  255. it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => {
  256. const { ctx, bash } = await setup()
  257. bash.handler = () => runResult('ok\n')
  258. const agent = registerFakeAgent(ctx, 'session-cwd')
  259. Object.assign(agent.session.header, { cwd: '/sessions/s1' })
  260. await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent)
  261. expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir'))
  262. await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent)
  263. expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path'))
  264. })
  265. it('omits workdir and the session id without an agent, so executor defaulting applies', async () => {
  266. const { ctx, bash } = await setup()
  267. bash.handler = () => runResult('ok\n')
  268. await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
  269. expect(bash.requests[0]).not.toHaveProperty('workdir')
  270. const dshEnv = bash.requests[0]?.dshEnv
  271. expect(dshEnv).toBeDefined()
  272. expect(dshEnv?.['DSH_SHELL']).toBe('1')
  273. expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String))
  274. expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID')
  275. })
  276. it('forwards exec.signal into the resolved request', async () => {
  277. const { ctx, bash } = await setup()
  278. const controller = new AbortController()
  279. bash.handler = () => runResult('ok\n')
  280. await ctx.tools.execute({
  281. signal: controller.signal,
  282. callId: CallId('call-signal'),
  283. name: 'pwsh',
  284. arguments: { command: 'Write-Output ok', description: 'ok' },
  285. })
  286. expect(bash.requests[0]?.signal).toBe(controller.signal)
  287. })
  288. it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => {
  289. const { ctx, bash } = await setup()
  290. bash.handler = () => runResult('out\n', {
  291. exitCode: 2,
  292. stderr: { text: 'err\n', truncated: false },
  293. timeoutMs: 5000,
  294. })
  295. const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' })
  296. expect(result.isError).toBe(false)
  297. if (result.isError) throw new Error('expected pwsh success')
  298. expect(result.value).toEqual({
  299. kind: 'foreground',
  300. exitCode: 2,
  301. signal: null,
  302. timedOut: false,
  303. aborted: false,
  304. timeoutMs: 5000,
  305. stdout: { text: 'out\n', truncated: false },
  306. stderr: { text: 'err\n', truncated: false },
  307. })
  308. expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]')
  309. })
  310. it('renders a clean exit without a marker and an empty body as (no output)', async () => {
  311. const { ctx, bash } = await setup()
  312. bash.handler = () => runResult('hi\n')
  313. const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
  314. expect(text(clean)).toBe('hi\n')
  315. bash.handler = () => runResult('')
  316. const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' })
  317. expect(text(empty)).toBe('(no output)')
  318. })
  319. it('renders stderr-only output without a stdout prefix', async () => {
  320. const { ctx, bash } = await setup()
  321. bash.handler = () => runResult('', {
  322. stderr: { text: 'err\n', truncated: false },
  323. exitCode: 1,
  324. })
  325. const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
  326. expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]')
  327. })
  328. it('inserts the separating newline before the stderr section when stdout lacks one', async () => {
  329. const { ctx, bash } = await setup()
  330. bash.handler = () => runResult('out', {
  331. stderr: { text: 'err\n', truncated: false },
  332. exitCode: 1,
  333. })
  334. const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
  335. expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]')
  336. })
  337. it('renders the truncation notice with the spill path, then markers', async () => {
  338. const { ctx, bash } = await setup()
  339. bash.handler = () => runResult('tail', {
  340. stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' },
  341. stderr: { text: '', truncated: false },
  342. })
  343. const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
  344. expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]')
  345. bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 })
  346. const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' })
  347. // A timeout kill carries both facts, mirroring the bash tool's markers.
  348. expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]')
  349. })
  350. it('renders the truncation notice with (unavailable) when no spill path exists', async () => {
  351. const { ctx, bash } = await setup()
  352. bash.handler = () => runResult('tail', {
  353. stdout: { text: 'tail', truncated: true },
  354. stderr: { text: '', truncated: false },
  355. })
  356. const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
  357. expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]')
  358. })
  359. it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => {
  360. const { ctx, bash } = await setup()
  361. bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' })
  362. const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' })
  363. expect(result.isError).toBe(true)
  364. expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
  365. })
  366. })
  367. describe('background execution through the task runtime', () => {
  368. it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
  369. const { ctx } = await setupWithTasks()
  370. const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true })
  371. expect(started.isError).toBe(false)
  372. if (started.isError) throw new Error('expected background pwsh success')
  373. expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' })
  374. expect(text(started)).toBe('started background task pwsh-1')
  375. const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok')
  376. expect(text(read)).toContain('bg-ok')
  377. // A later read reports the terminal outcome in the generic status line.
  378. const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]')
  379. expect(final.isError).toBe(false)
  380. })
  381. it('a running background task is killable through the REAL task_kill tool', async () => {
  382. const { ctx, bash } = await setupWithTasks()
  383. bash.backgroundHandler = () => killableProcess()
  384. await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  385. const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' })
  386. expect(text(killed)).toBe('requested cancellation of task pwsh-1')
  387. // The cancel reached the process handle; the task settles as killed with
  388. // the signal detail mapped by processOutcome.
  389. const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true })
  390. expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
  391. })
  392. it('a background task started by an agent is registered with that agent as owner', async () => {
  393. const { ctx } = await setupWithTasks()
  394. const agent = registerFakeAgent(ctx, 'sess-owner')
  395. const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent)
  396. expect(text(started)).toBe('started background task pwsh-1')
  397. const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' })
  398. expect(anon.isError).toBe(true)
  399. expect(text(anon)).toMatch(/belongs to another session/)
  400. const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent)
  401. expect(killed.isError).toBe(false)
  402. await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan
  403. })
  404. it('fails loud when the task runtime is not loaded', async () => {
  405. const { ctx } = await setup() // no LocalTaskService / ToolTasks
  406. const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  407. expect(result.isError).toBe(true)
  408. expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
  409. })
  410. it('a pre-aborted call is skipped before the process starts', async () => {
  411. const { ctx, bash } = await setupWithTasks()
  412. const controller = new AbortController()
  413. controller.abort()
  414. const result = await ctx.tools.execute({
  415. callId: CallId('call-pre-aborted'),
  416. name: 'pwsh',
  417. arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true },
  418. signal: controller.signal,
  419. })
  420. expect(result.isError).toBe(true)
  421. expect(result.error).toEqual({
  422. message: 'tool call aborted before dispatch',
  423. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  424. })
  425. expect(bash.startCalls).toBe(0)
  426. })
  427. it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
  428. // With no control surface, task preflight fails before the executor can spawn.
  429. const ctx = new Context()
  430. await ctx.plugin(SystemPrompt)
  431. await ctx.plugin(ToolRegistry)
  432. await ctx.plugin(LocalTaskService)
  433. await ctx.plugin(BashEnvPlugin)
  434. await ctx.plugin(FakeBash)
  435. await ctx.plugin(ToolPwsh)
  436. const bash = ctx.bash as FakeBash
  437. const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  438. expect(result.isError).toBe(true)
  439. expect(text(result)).toContain('no control surface is attached')
  440. // Declare-then-execute: the failed preflight means no process ever ran.
  441. expect(bash.startCalls).toBe(0)
  442. })
  443. it('enableRunInBackground: false removes the parameter and flips the description', async () => {
  444. const { ctx } = await setup({ enableRunInBackground: false })
  445. const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')!
  446. expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
  447. .toEqual(['command', 'description', 'timeoutMs', 'workdir'])
  448. expect(schema.description).toContain('Background execution is not available')
  449. expect(schema.description).not.toContain('run_in_background')
  450. // Schema omission is advertising; execution must also enforce the opt-out.
  451. const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true })
  452. expect(forced.isError).toBe(true)
  453. expect(text(forced)).toContain('run_in_background is disabled for this deployment')
  454. const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' })
  455. expect(foreground.isError).toBe(false)
  456. })
  457. it('applies the built-in background default when apply() receives a bare config', async () => {
  458. // Bypasses the schemastery defaults on purpose: apply() must stand on its
  459. // own `?? true` fallback when embedded programmatically without the schema.
  460. const ctx = new Context()
  461. await ctx.plugin(SystemPrompt)
  462. await ctx.plugin(ToolRegistry)
  463. await ctx.plugin(BashEnvPlugin)
  464. await ctx.plugin(FakeBash)
  465. ToolPwsh.apply(ctx, {})
  466. const schema = ctx.tools.schemas()[0]!
  467. expect(schema.parameters.properties).toHaveProperty('run_in_background')
  468. expect(schema.description).toContain('task_output')
  469. })
  470. })
  471. describe('UI presentation', () => {
  472. it('a real execute presents a completed foreground run as a terminal card with the parsed exit pill', async () => {
  473. const { ctx, bash } = await setup()
  474. bash.handler = () => runResult('hi\n')
  475. const args = { command: 'Write-Output hi', description: 'say hi' }
  476. const result = await call(ctx, 'pwsh', args)
  477. const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
  478. // A terminal result keeps the RAW bytes (newlines intact) a terminal
  479. // renderer needs; a clean run renders no exit marker, so the body is the
  480. // raw output with a clean exit-0 pill, mirroring the bash tool.
  481. expect(view).toEqual({ card: 'terminal', output: 'hi\n', exitCode: 0 })
  482. })
  483. it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
  484. const { ctx } = await setup()
  485. const definition = ctx.tools.get('pwsh')
  486. expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' }))
  487. .toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' })
  488. expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' }))
  489. .toMatchObject({ cwd: 'C:\\work' })
  490. })
  491. it('a background pending call renders the generic card like the bash tool', async () => {
  492. const { ctx } = await setup()
  493. const definition = ctx.tools.get('pwsh')
  494. expect(definition?.presentCall?.({
  495. command: 'Start-Sleep -Seconds 60',
  496. description: 'long wait',
  497. run_in_background: true,
  498. })).toEqual({
  499. card: 'generic',
  500. title: 'Start-Sleep -Seconds 60',
  501. kind: 'execute',
  502. rawInput: 'Start-Sleep -Seconds 60',
  503. content: [{ type: 'text', text: 'long wait' }],
  504. })
  505. })
  506. it('presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
  507. const { ctx } = await setup()
  508. const present = ctx.tools.get('pwsh')
  509. const args = { command: 'x', description: 'x' }
  510. expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }))
  511. .toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
  512. expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }))
  513. .toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
  514. })
  515. it('presentResult: markers a pill CANNOT show (timeout) stay in the terminal output', async () => {
  516. const { ctx } = await setup()
  517. const args = { command: 'x', description: 'x' }
  518. expect(ctx.tools.get('pwsh')?.presentResult?.(
  519. args,
  520. { content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
  521. )).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
  522. })
  523. it('presentResult exit parse is the inverse of renderPwshResult markers (round-trip)', async () => {
  524. const { ctx } = await setup()
  525. const present = ctx.tools.get('pwsh')!
  526. const base = {
  527. aborted: false,
  528. timeoutMs: 1000,
  529. stdout: { text: 'out', truncated: false },
  530. stderr: { text: '', truncated: false },
  531. }
  532. const cases = [
  533. { result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
  534. { result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
  535. { result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
  536. // A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
  537. { result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
  538. ]
  539. for (const c of cases) {
  540. const rendered = renderPwshResult(c.result)
  541. const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
  542. // Drop card + output; the remaining fields are the parsed exit.
  543. const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
  544. expect(exit).toEqual(c.expect)
  545. // Whatever the parse consumed is gone from the body, so a card with an
  546. // exit pill never shows the same status twice.
  547. expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
  548. }
  549. })
  550. it('presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
  551. const { ctx } = await setup()
  552. const args = { command: 'Write-Output "[exit code: 5]"', description: 'print' }
  553. // A successful command may print marker-like text. A clean result appends no marker or
  554. // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
  555. const out = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
  556. expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
  557. // Same for a fake signal marker with no leading newline.
  558. const sig = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
  559. expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
  560. })
  561. it('presentResult: a run_in_background ack is a generic card and carries no exit pill', async () => {
  562. const { ctx } = await setup()
  563. const result = ctx.tools.get('pwsh')!.presentResult!(
  564. { command: 'Start-Sleep -Seconds 60', description: 'long wait', run_in_background: true },
  565. { content: [{ type: 'text', text: 'started background task pwsh-1' }], isError: false },
  566. )
  567. expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task pwsh-1\n```' }] })
  568. })
  569. it('presentResult: an isError result is a generic card (no real process exit to report)', async () => {
  570. const { ctx } = await setup()
  571. const out = ctx.tools.get('pwsh')!.presentResult!(
  572. { command: 'x', description: 'x' },
  573. { content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
  574. )
  575. expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
  576. })
  577. it('presentResult falls back to undefined for multi-block or non-text content', async () => {
  578. const { ctx } = await setup()
  579. const definition = ctx.tools.get('pwsh')
  580. const args = { command: 'Write-Output hi', description: 'say hi' }
  581. const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false }
  582. expect(definition?.presentResult?.(args, multi as never)).toBeUndefined()
  583. const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false }
  584. expect(definition?.presentResult?.(args, image as never)).toBeUndefined()
  585. })
  586. })
  587. describe('renderPwshProcessRead', () => {
  588. const base: BashProcessRead = { delta: 'out\n', lossy: false }
  589. it('returns the delta verbatim for a lossless read', () => {
  590. expect(renderPwshProcessRead(base)).toBe('out\n')
  591. expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('')
  592. })
  593. it('appends the loss notice with the available spill paths', () => {
  594. expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' }))
  595. .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]')
  596. expect(renderPwshProcessRead({
  597. ...base,
  598. lossy: true,
  599. stdoutSpillPath: 'C:\\spill\\out.log',
  600. stderrSpillPath: 'C:\\spill\\err.log',
  601. }))
  602. .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]')
  603. })
  604. it('reports (unavailable) when a lossy read has no safe spill path', () => {
  605. expect(renderPwshProcessRead({ ...base, lossy: true }))
  606. .toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
  607. })
  608. it('an empty lossy delta is the notice alone', () => {
  609. expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' }))
  610. .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]')
  611. })
  612. it('inserts the separating newline only when the delta lacks one', () => {
  613. expect(renderPwshProcessRead({ delta: 'tail', lossy: true }))
  614. .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
  615. expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
  616. .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
  617. })
  618. })
  619. describe('processOutcome', () => {
  620. function settled(over: Partial<BashProcess>): BashProcess {
  621. return {
  622. status: 'completed',
  623. exitCode: 0,
  624. signal: null,
  625. done: Promise.resolve(),
  626. readOutput: () => ({ delta: '', lossy: false }),
  627. kill: () => false,
  628. ...over,
  629. }
  630. }
  631. it('maps a signal-killed process to killed with the signal detail', () => {
  632. expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
  633. .toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
  634. })
  635. it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
  636. expect(processOutcome(settled({ status: 'killed', exitCode: null })))
  637. .toEqual({ status: 'killed', detail: 'killed before exit' })
  638. })
  639. it('maps a completed process to its exit code', () => {
  640. expect(processOutcome(settled({ exitCode: 3 })))
  641. .toEqual({ status: 'completed', detail: 'exit code: 3' })
  642. })
  643. it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
  644. expect(processOutcome(settled({ exitCode: null })))
  645. .toEqual({ status: 'completed', detail: 'exit code: 0' })
  646. })
  647. })