tools.spec.ts 23 KB

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