tools.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import { 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, { Inbox } from '@deepseek-ai/dsh-agent'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  8. import ToolRegistry, { renderToolsSdk } from '@deepseek-ai/dsh-tools'
  9. import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
  10. import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
  11. import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
  12. import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
  13. import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
  14. import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
  15. function fakeAgent(ctx: Context, rawId: string): Agent {
  16. const scope = ctx.plugin(() => {})
  17. const id = SessionId(rawId)
  18. const session = Session.create(id)
  19. const agent: Agent = {
  20. id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  21. status: 'idle',
  22. ctx: scope.ctx,
  23. send: () => {},
  24. followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
  25. runMaintenance: task => task(new AbortController().signal),
  26. whenIdle: () => Promise.resolve(),
  27. }
  28. ctx.agents.register(agent)
  29. return agent
  30. }
  31. class StubSession implements PtyBackendSession {
  32. readonly motd = 'stub prompt'
  33. readonly pid = 42
  34. statusValue: PtySessionStatus = { kind: 'running' }
  35. operation: PtySendOperation | undefined
  36. autoSettle = true
  37. rejectOperation = false
  38. closeGate: PromiseWithResolvers<undefined> | undefined
  39. viewport = 'command output'
  40. delta = 'live output'
  41. deltaTruncated = false
  42. startSend(_request: PtySendRequest): PtySendOperation {
  43. let settle!: () => void
  44. let reject!: (error: unknown) => void
  45. let cancelled = false
  46. const done = new Promise<void>((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise }).then(() => ({
  47. viewport: cancelled ? '^C' : this.viewport,
  48. waitReason: 'stdin_read' as const,
  49. sessionStatus: this.statusValue,
  50. truncated: false,
  51. }))
  52. const operation: PtySendOperation = {
  53. done,
  54. readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }),
  55. cancel: () => {
  56. if (cancelled) return false
  57. cancelled = true
  58. settle()
  59. return true
  60. },
  61. }
  62. this.operation = operation
  63. if (this.rejectOperation) queueMicrotask(() => { reject(new Error('operation failed')) })
  64. else if (this.autoSettle) queueMicrotask(settle)
  65. return operation
  66. }
  67. read() {
  68. return { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
  69. }
  70. async signal(signal: PtySignal) {
  71. return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 10 : 11 }
  72. }
  73. status() { return this.statusValue }
  74. async close() {
  75. if (this.closeGate !== undefined) await this.closeGate.promise
  76. this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
  77. }
  78. }
  79. function stubBackend() {
  80. const sessions: StubSession[] = []
  81. const backend: PtyBackend = {
  82. type: 'stub',
  83. async spawn() {
  84. const session = new StubSession()
  85. sessions.push(session)
  86. return session
  87. },
  88. }
  89. return { backend, sessions }
  90. }
  91. async function setup(tasks: boolean, config: ToolPty.Config = {}) {
  92. const base = await setupBase(tasks)
  93. await base.ctx.plugin(ToolPty, config)
  94. return base
  95. }
  96. async function setupBase(tasks: boolean) {
  97. const ctx = new Context()
  98. await ctx.plugin(SystemPrompt)
  99. await ctx.plugin(ToolRegistry)
  100. await ctx.plugin(AgentRegistry)
  101. await ctx.plugin(PtyService)
  102. const stub = stubBackend()
  103. ctx.pty.registerBackend(stub.backend)
  104. if (tasks) {
  105. await ctx.plugin(LocalTaskService)
  106. await ctx.plugin(ToolTasks)
  107. }
  108. return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
  109. }
  110. let callNumber = 0
  111. const TOOL_NAMES = ['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'] as const
  112. const testToolSignal = new AbortController().signal
  113. function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
  114. return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, ...agent ? { agent } : {} })
  115. }
  116. function callWithSignal(ctx: Context, name: string, args: unknown, agent: Agent, signal: AbortSignal) {
  117. return ctx.tools.execute({ callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, agent, signal })
  118. }
  119. function text(result: { content: { type: string; text?: string }[] }): string {
  120. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  121. }
  122. describe('tool-pty foreground surface', () => {
  123. it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => {
  124. const { ctx, agent } = await setup(false)
  125. expect(TOOL_NAMES.every(name => ctx.tools.get(name) !== undefined)).toBe(true)
  126. const spawned = await call(ctx, 'terminal_open', { type: 'stub', name: 'main' }, agent)
  127. expect(text(spawned)).toContain('started terminal session pty-1 (main)')
  128. expect(spawned).toMatchObject({
  129. isError: false,
  130. value: {
  131. sessionId: 'pty-1',
  132. name: 'main',
  133. type: 'stub',
  134. pid: 42,
  135. status: { kind: 'running' },
  136. motd: 'stub prompt',
  137. },
  138. })
  139. const listed = await call(ctx, 'terminal_list', {}, agent)
  140. expect(text(listed)).toContain('pty-1 (main) [stub] running pid=42')
  141. expect(listed).toMatchObject({ isError: false, value: [{ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 42, status: { kind: 'running' } }] })
  142. const read = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent)
  143. expect(text(read)).toContain('history\n[lines: 0-1 of 1]')
  144. expect(read).toMatchObject({ isError: false, value: { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false } })
  145. const signalled = await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent)
  146. expect(text(signalled)).toBe('delivered SIGINT to foreground process group 10')
  147. expect(signalled).toMatchObject({ isError: false, value: { delivered: true, targetPgid: 10 } })
  148. const sent = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'echo hi' }, agent)
  149. expect(text(sent)).toContain('command output\n[wait: stdin_read]\n[session: running]')
  150. expect(sent).toMatchObject({
  151. isError: false,
  152. value: {
  153. kind: 'foreground',
  154. viewport: 'command output',
  155. waitReason: 'stdin_read',
  156. sessionStatus: { kind: 'running' },
  157. truncated: false,
  158. },
  159. meta: {
  160. viewport: 'command output',
  161. waitReason: 'stdin_read',
  162. sessionStatus: { kind: 'running' },
  163. truncated: false,
  164. },
  165. })
  166. const closed = await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent)
  167. expect(text(closed)).toBe('closed terminal session pty-1')
  168. expect(closed).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'closed' } })
  169. const empty = await call(ctx, 'terminal_list', {}, agent)
  170. expect(text(empty)).toBe('(no terminal sessions)')
  171. expect(empty).toMatchObject({ isError: false, value: [] })
  172. })
  173. it('projects every terminal DTO into the generated Code Mode output map', async () => {
  174. const { ctx } = await setup(false)
  175. const schemas = TOOL_NAMES.map((toolName): ToolSdkSchema => {
  176. const definition = ctx.tools.get(toolName)
  177. if (definition === undefined) throw new Error(`missing terminal tool ${toolName}`)
  178. return {
  179. name: definition.name,
  180. description: definition.description,
  181. parameters: definition.parameters,
  182. output: definition.output.schema,
  183. }
  184. })
  185. const sdk = renderToolsSdk(schemas)
  186. const outputMapStart = sdk.indexOf('interface ToolOutputMap')
  187. const outputMapEnd = sdk.indexOf('\n\ntype ToolName', outputMapStart)
  188. expect(sdk.slice(outputMapStart, outputMapEnd)).toMatchInlineSnapshot(`
  189. "interface ToolOutputMap {
  190. terminal_close: {
  191. sessionId: string;
  192. outcome: "closed" | "already-closing";
  193. };
  194. terminal_list: ({
  195. sessionId: string;
  196. name?: string;
  197. type: string;
  198. pid?: number;
  199. status: {
  200. kind: "running";
  201. } | {
  202. kind: "exited";
  203. exitCode: number | null;
  204. signal: string | null;
  205. };
  206. })[];
  207. terminal_open: {
  208. sessionId: string;
  209. name?: string;
  210. type: string;
  211. pid?: number;
  212. status: {
  213. kind: "running";
  214. } | {
  215. kind: "exited";
  216. exitCode: number | null;
  217. signal: string | null;
  218. };
  219. motd: string;
  220. };
  221. terminal_read: {
  222. text: string;
  223. totalLines: number;
  224. lineBegin: number;
  225. lineEnd: number;
  226. truncated: boolean;
  227. };
  228. terminal_send: {
  229. kind: "background";
  230. taskId: string;
  231. } | {
  232. kind: "foreground";
  233. viewport: string;
  234. waitReason: "stdin_read" | "inferred_idle" | "timeout" | "session_exit";
  235. sessionStatus: {
  236. kind: "running";
  237. } | {
  238. kind: "exited";
  239. exitCode: number | null;
  240. signal: string | null;
  241. };
  242. truncated: boolean;
  243. };
  244. terminal_signal: {
  245. delivered: true;
  246. targetPgid: number;
  247. };
  248. }"
  249. `)
  250. })
  251. it('fails without an initiating agent and rejects background before writing', async () => {
  252. const { ctx, agent, stub } = await setup(false)
  253. expect((await call(ctx, 'terminal_open', { type: 'stub' })).isError).toBe(true)
  254. await call(ctx, 'terminal_open', { type: 'stub' }, agent)
  255. const result = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'sleep 1', run_in_background: true }, agent)
  256. expect(result.isError).toBe(true)
  257. expect(stub.sessions[0]?.operation).toBeUndefined()
  258. })
  259. it('validates required values and forwards optional spawn/read arguments', async () => {
  260. const { ctx, agent } = await setup(false)
  261. expect((await call(ctx, 'terminal_open', { type: '' }, agent)).isError).toBe(true)
  262. expect((await call(ctx, 'terminal_send', { sessionId: '', text: 'x' }, agent)).isError).toBe(true)
  263. expect((await call(ctx, 'terminal_send', { sessionId: 1, text: 'x' }, agent)).isError).toBe(true)
  264. expect((await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 1 }, agent)).isError).toBe(true)
  265. await call(ctx, 'terminal_open', { type: 'stub', name: 'named', cwd: '/tmp' }, agent)
  266. expect(text(await call(ctx, 'terminal_read', { sessionId: 'pty-1', offset: 2, count: 3 }, agent))).toContain('history')
  267. })
  268. it('declares terminal presentation only for foreground sends', async () => {
  269. const { ctx } = await setup(false)
  270. const definition = ctx.tools.get('terminal_send')
  271. expect(definition?.presentCall?.({ sessionId: 'pty-1', text: 'python3' })).toMatchObject({ card: 'terminal', title: 'python3' })
  272. expect(definition?.presentCall?.({ sessionId: 'pty-1', text: 'make', run_in_background: true })).toMatchObject({ card: 'generic' })
  273. expect(definition?.presentCall?.({ sessionId: 'pty-1', text: '' })).toMatchObject({ card: 'terminal', title: '(send input)' })
  274. expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x', run_in_background: true }, { content: [], isError: false })).toBeUndefined()
  275. expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [], isError: true })).toBeUndefined()
  276. expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [], isError: false })).toBeUndefined()
  277. expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }], isError: false })).toBeUndefined()
  278. expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [undefined as never], isError: false })).toBeUndefined()
  279. expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [{ type: 'text', text: 'ok' }], isError: false })).toEqual({ card: 'terminal', output: 'ok' })
  280. expect(ctx.tools.get('terminal_open')?.presentCall?.({ type: 'stub' })).toMatchObject({ card: 'generic', title: 'Open terminal stub' })
  281. expect(ctx.tools.get('terminal_open')?.presentCall?.({ type: 'stub', name: 'main' })).toMatchObject({ card: 'generic', title: 'Open terminal main' })
  282. expect(ctx.tools.get('terminal_read')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Read terminal pty-1' })
  283. expect(ctx.tools.get('terminal_signal')?.presentCall?.({ sessionId: 'pty-1', signal: 'SIGINT' })).toMatchObject({ card: 'generic', title: 'Signal terminal pty-1' })
  284. expect(ctx.tools.get('terminal_close')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Close terminal pty-1' })
  285. expect(ctx.tools.get('terminal_list')?.presentCall?.({})).toMatchObject({ card: 'generic', title: 'List terminal sessions' })
  286. })
  287. it('configuration-gates background sends and validates the final result bound', async () => {
  288. const disabled = await setup(true, { enableRunInBackground: false })
  289. const definition = disabled.ctx.tools.get('terminal_send')
  290. expect(definition?.parameters).not.toHaveProperty('properties.run_in_background')
  291. expect(definition?.description).not.toContain('Background mode')
  292. await call(disabled.ctx, 'terminal_open', { type: 'stub' }, disabled.agent)
  293. expect((await call(disabled.ctx, 'terminal_send', {
  294. sessionId: 'pty-1', text: 'work', run_in_background: true,
  295. }, disabled.agent)).isError).toBe(true)
  296. const defaults = await setupBase(false)
  297. ToolPty.apply(defaults.ctx)
  298. expect(defaults.ctx.tools.get('terminal_send')?.parameters).toHaveProperty('properties.run_in_background')
  299. const invalid = await setupBase(false)
  300. expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes')
  301. expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 63 }) }).toThrow('at least 64')
  302. })
  303. it('bounds normalized errors and preserves allocated ids at the minimum result cap', async () => {
  304. const { ctx, agent } = await setup(true, { maxResultBytes: 64 })
  305. const failed = await call(ctx, 'terminal_open', { type: 'x'.repeat(1_000) }, agent)
  306. expect(failed.isError).toBe(true)
  307. expect(Buffer.byteLength(text(failed))).toBeLessThanOrEqual(64)
  308. expect(text(failed)).toContain('[output truncated]')
  309. const opened = await call(ctx, 'terminal_open', { type: 'stub', name: 'n'.repeat(1_000) }, agent)
  310. expect(text(opened)).toContain('pty-1')
  311. expect(Buffer.byteLength(text(opened))).toBeLessThanOrEqual(64)
  312. const background = await call(ctx, 'terminal_send', {
  313. sessionId: 'pty-1', text: 'work', run_in_background: true,
  314. }, agent)
  315. expect(text(background)).toContain('pty-send-1')
  316. expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
  317. })
  318. it('bounds terminal results after policy decisions and pipeline failures', async () => {
  319. const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
  320. ctx.on('tools/pre-execute', async (exec, next) => {
  321. if (exec.name === 'terminal_list') return { kind: 'deny', reason: 'd'.repeat(1_000) }
  322. if (exec.name === 'terminal_signal') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
  323. return next()
  324. })
  325. ctx.on('tools/execute', async (exec, next) => {
  326. if (exec.name === 'terminal_close') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
  327. return next()
  328. })
  329. ctx.on('tools/post-execute', async (exec, _result, next) => {
  330. if (exec.name === 'terminal_open') {
  331. return { kind: 'accept', content: [{ type: 'text', text: 'a'.repeat(1_000) }] }
  332. }
  333. if (exec.name === 'terminal_read') {
  334. return { kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] }
  335. }
  336. if (exec.name === 'terminal_send') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
  337. return next()
  338. })
  339. const denied = await call(ctx, 'terminal_list', {}, agent)
  340. expect(denied.isError).toBe(true)
  341. expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
  342. expect(text(denied)).toContain('[output truncated]')
  343. const replaced = await call(ctx, 'terminal_open', { type: 'stub' }, agent)
  344. expect(replaced.isError).toBe(false)
  345. expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
  346. expect(text(replaced)).toContain('[output truncated]')
  347. const blocked = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent)
  348. expect(blocked.isError).toBe(true)
  349. expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
  350. expect(text(blocked)).toContain('[output truncated]')
  351. const failures = [
  352. await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent),
  353. await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent),
  354. await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'work' }, agent),
  355. ]
  356. for (const failure of failures) {
  357. expect(failure.isError).toBe(true)
  358. expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64)
  359. expect(text(failure)).toContain('[output truncated]')
  360. }
  361. })
  362. it('leaves a structured around-dispatch failure unchanged', async () => {
  363. const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
  364. ctx.on('tools/execute', async (exec, next) => exec.name === 'terminal_list'
  365. ? { content: [], isError: true, error: { message: 'structured failure' } }
  366. : next())
  367. const result = await call(ctx, 'terminal_list', {}, agent)
  368. expect(result.isError).toBe(true)
  369. expect(result.content).toEqual([])
  370. })
  371. })
  372. describe('tool-pty task integration', () => {
  373. it('registers a generic task and exposes incremental output', async () => {
  374. const { ctx, agent } = await setup(true)
  375. await call(ctx, 'terminal_open', { type: 'stub' }, agent)
  376. const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent)
  377. expect(text(started)).toBe('started background task pty-send-1')
  378. expect(started).toMatchObject({ isError: false, value: { kind: 'background', taskId: 'pty-send-1' } })
  379. const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
  380. expect(text(output)).toContain('live output')
  381. expect(text(output)).toContain('[status: completed, wait: stdin_read]')
  382. })
  383. it('bounds foreground and background results after terminal and task metadata', async () => {
  384. const { ctx, agent, stub } = await setup(true, { maxResultBytes: 64 })
  385. await call(ctx, 'terminal_open', { type: 'stub' }, agent)
  386. stub.sessions[0]!.viewport = '界'.repeat(100)
  387. const foreground = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'foreground' }, agent)
  388. expect(Buffer.byteLength(text(foreground))).toBeLessThanOrEqual(64)
  389. stub.sessions[0]!.delta = '界'.repeat(100)
  390. stub.sessions[0]!.deltaTruncated = true
  391. await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent)
  392. const background = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
  393. expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
  394. expect(text(background)).toContain('[status: completed')
  395. expect(text(background).match(/\[output truncated\]/g)).toHaveLength(1)
  396. expect(text(background)).toContain('[output truncated]\n[status: completed')
  397. })
  398. it('rejects pre-aborted background calls, maps task cancellation, and contains operation failure', async () => {
  399. const { ctx, agent, stub } = await setup(true)
  400. await call(ctx, 'terminal_open', { type: 'stub' }, agent)
  401. const controller = new AbortController()
  402. controller.abort()
  403. expect((await callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'x', run_in_background: true }, agent, controller.signal)).isError).toBe(true)
  404. stub.sessions[0]!.autoSettle = false
  405. expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent))).toContain('pty-send-1')
  406. expect(text(await call(ctx, 'task_kill', { task_id: 'pty-send-1' }, agent))).toContain('requested cancellation')
  407. await new Promise(resolve => setTimeout(resolve, 0))
  408. expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-1' }, agent))).toContain('[status: killed')
  409. stub.sessions[0]!.rejectOperation = true
  410. stub.sessions[0]!.autoSettle = false
  411. expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent))).toContain('pty-send-2')
  412. await new Promise(resolve => setTimeout(resolve, 0))
  413. expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-2' }, agent))).toContain('[status: failed')
  414. })
  415. it('reports foreground cancellation after the terminal operation settles', async () => {
  416. const { ctx, agent, stub } = await setup(false)
  417. await call(ctx, 'terminal_open', { type: 'stub' }, agent)
  418. stub.sessions[0]!.autoSettle = false
  419. const controller = new AbortController()
  420. const pending = callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'sleep' }, agent, controller.signal)
  421. await Promise.resolve()
  422. controller.abort()
  423. stub.sessions[0]!.operation?.cancel()
  424. expect((await pending).isError).toBe(true)
  425. })
  426. it('renders the already-closing kill result', async () => {
  427. const { ctx, agent, stub } = await setup(false)
  428. await call(ctx, 'terminal_open', { type: 'stub' }, agent)
  429. stub.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
  430. const first = ctx.pty.kill(agent, PtySessionId('pty-1'))
  431. const second = call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent)
  432. stub.sessions[0]!.closeGate?.resolve(undefined)
  433. await first
  434. const result = await second
  435. expect(text(result)).toBe('terminal session pty-1 was already closing')
  436. expect(result).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'already-closing' } })
  437. })
  438. it('renders an exited session detail for background completion', async () => {
  439. const { ctx, agent, stub } = await setup(true)
  440. await call(ctx, 'terminal_open', { type: 'stub' }, agent)
  441. stub.sessions[0]!.statusValue = { kind: 'exited', exitCode: null, signal: null }
  442. await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
  443. const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
  444. expect(text(output)).toContain('session exited: unknown')
  445. })
  446. })
  447. describe('tool-pty plugin shape', () => {
  448. it('is a named function plugin with no default export', () => {
  449. expect('default' in ToolPty).toBe(false)
  450. expect(ToolPty.name).toBe('tool-pty')
  451. expect(ToolPty.inject).toEqual(['pty', 'tools', 'systemPrompt'])
  452. })
  453. })