tmux-context.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  5. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  6. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  7. import { ShellExecutor } from '@deepseek-ai/dsh-shell'
  8. import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
  9. import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
  10. import type { Config } from '@deepseek-ai/dsh-tmux-context'
  11. import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
  12. const SIGNAL = new AbortController().signal
  13. /** One `#{...}`-joined tmux reading line for the eight queried fields. */
  14. function tmuxLine(fields: {
  15. sessionName?: string
  16. windowIndex?: string
  17. windowName?: string
  18. paneIndex?: string
  19. paneId?: string
  20. windowActive?: string
  21. paneActive?: string
  22. windowLayout?: string
  23. } = {}): string {
  24. return [
  25. fields.sessionName ?? '0',
  26. fields.windowIndex ?? '1',
  27. fields.windowName ?? 'node',
  28. fields.paneIndex ?? '2',
  29. fields.paneId ?? '%90',
  30. fields.windowActive ?? '1',
  31. fields.paneActive ?? '0',
  32. fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
  33. ].join('\\t')
  34. }
  35. function runResult(stdout: string, overrides: Partial<ShellRunResult> = {}): ShellRunResult {
  36. return {
  37. exitCode: 0,
  38. signal: null,
  39. timedOut: false,
  40. aborted: false,
  41. timeoutMs: 60_000,
  42. stdout: { text: stdout, truncated: false },
  43. stderr: { text: '', truncated: false },
  44. ...overrides,
  45. }
  46. }
  47. /** A scriptable fake `ctx.shell` recording the command it was asked to run. */
  48. class FakeBash extends ShellExecutor {
  49. commands: string[] = []
  50. result: ShellRunResult = runResult(`${tmuxLine()}\n`)
  51. runError?: Error
  52. resolveError?: Error
  53. override resolve(request: ShellExecRequest): ShellExecSpec {
  54. if (this.resolveError) throw this.resolveError
  55. return {
  56. command: request.command,
  57. workdir: request.workdir ?? '/work',
  58. timeoutMs: request.timeoutMs ?? 60_000,
  59. stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
  60. signal: request.signal,
  61. sandboxPolicy: request.sandboxPolicy,
  62. }
  63. }
  64. override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
  65. this.commands.push(spec.command)
  66. if (this.runError) throw this.runError
  67. return this.result
  68. }
  69. override async start(): Promise<ShellProcess> {
  70. throw new Error('tmux-context must never start a background job')
  71. }
  72. }
  73. async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }>
  74. async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }>
  75. async function mount(
  76. config: Config = {},
  77. withBash = false,
  78. ): Promise<{ ctx: Context; bash: FakeBash | undefined }> {
  79. const ctx = new Context()
  80. await ctx.plugin(AgentRegistry)
  81. await ctx.plugin(SessionProjectionRegistry)
  82. let bash: FakeBash | undefined
  83. if (withBash) {
  84. await ctx.plugin(FakeBash)
  85. bash = ctx.shell as FakeBash
  86. }
  87. await ctx.plugin(tmuxContext, config)
  88. return { ctx, bash }
  89. }
  90. function sessionAgent(session: Session, id = 'agent'): Agent {
  91. const agent: Agent = {
  92. id: SessionId(id),
  93. options: {},
  94. session,
  95. inbox: unsupportedInbox(),
  96. status: 'running',
  97. ctx: new Context(),
  98. send: () => {},
  99. followup: () => {},
  100. steer: () => {},
  101. inject: () => { throw new Error('tmux-context must append directly to the open step') },
  102. cancel() {},
  103. runMaintenance: task => task(new AbortController().signal),
  104. whenIdle: () => Promise.resolve(),
  105. }
  106. return agent
  107. }
  108. function openMessageTurn(session: Session, turn: number): void {
  109. session.append('turn/start', { turn })
  110. session.append('user/message', createUserMessage({
  111. content: [{ type: 'text', text: `turn ${turn}` }],
  112. source: { kind: 'user' },
  113. }), { surfaceOp: 'append' })
  114. }
  115. function contextTexts(session: Session): string[] {
  116. const texts: string[] = []
  117. for (const event of session.snapshotEvents()) {
  118. if (event.type === 'user/message'
  119. && event.data.source.kind === 'plugin'
  120. && event.data.source.plugin === 'tmux-context') {
  121. texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
  122. }
  123. }
  124. return texts
  125. }
  126. async function fire(
  127. ctx: Context,
  128. agent: Agent,
  129. turn: number,
  130. step: number,
  131. signal: AbortSignal = SIGNAL,
  132. ): Promise<void> {
  133. const decision = await agentEvents(ctx, agent).waterfall(
  134. 'agent/pre-step',
  135. { messages: [], turn, step, signal },
  136. () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
  137. )
  138. if (decision.kind === 'enter') {
  139. for (const message of decision.messages) {
  140. agent.session.append('user/message', message, { surfaceOp: 'append' })
  141. }
  142. }
  143. }
  144. afterEach(() => {
  145. vi.restoreAllMocks()
  146. vi.useRealTimers()
  147. })
  148. describe('tmux-context injection', () => {
  149. it('injects the tmux location on the first step of a turn', async () => {
  150. const { ctx } = await mount({}, true)
  151. const session = Session.create(SessionId('first'))
  152. openMessageTurn(session, 1)
  153. await fire(ctx, sessionAgent(session), 1, 1)
  154. expect(contextTexts(session)).toEqual([
  155. 'tmux location (turn 1):\n'
  156. + 'session 0, window 1 "node", pane 2 %90\n'
  157. + 'window active=1, pane active=0, '
  158. + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
  159. ])
  160. const event = session.snapshotEvents().at(-1)
  161. if (event?.type !== 'user/message') throw new Error('missing tmux context')
  162. // `snapshot` form: one named contribution carrying exactly the reading the
  163. // model saw, so a consumer attributes it without re-splitting prose.
  164. expect(event.data.source).toMatchObject({
  165. kind: 'plugin',
  166. plugin: 'tmux-context',
  167. form: 'snapshot',
  168. sections: [{ name: 'tmux-context' }],
  169. })
  170. expect(event.surfaceOp).toBe('append')
  171. })
  172. it('queries the pane this process runs in and matches its controlling tty', async () => {
  173. const { ctx, bash } = await mount({}, true)
  174. const session = Session.create(SessionId('command'))
  175. openMessageTurn(session, 1)
  176. await fire(ctx, sessionAgent(session), 1, 1)
  177. expect(bash.commands).toHaveLength(1)
  178. const command = bash.commands[0]!
  179. expect(command).toContain('[ -n "$TMUX_PANE" ]')
  180. expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p')
  181. // Guards against an inherited $TMUX_PANE: the pane's tty must equal this
  182. // process's controlling tty (resolved for this exact pid).
  183. expect(command).toContain(`ps -o tty= -p ${process.pid}`)
  184. expect(command).toContain('#{pane_tty}')
  185. expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]')
  186. })
  187. it('does not run on later steps of a turn', async () => {
  188. const { ctx, bash } = await mount({}, true)
  189. const session = Session.create(SessionId('later-step'))
  190. openMessageTurn(session, 1)
  191. await fire(ctx, sessionAgent(session), 1, 2)
  192. expect(bash.commands).toHaveLength(0)
  193. expect(contextTexts(session)).toHaveLength(0)
  194. })
  195. it('re-injects a new turn only when tmux state changed', async () => {
  196. const { ctx, bash } = await mount({}, true)
  197. const session = Session.create(SessionId('change'))
  198. const agent = sessionAgent(session)
  199. openMessageTurn(session, 1)
  200. await fire(ctx, agent, 1, 1)
  201. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  202. // Same state on turn 2: suppressed.
  203. openMessageTurn(session, 2)
  204. await fire(ctx, agent, 2, 1)
  205. session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  206. expect(contextTexts(session)).toHaveLength(1)
  207. // Moved pane on turn 3: re-injected.
  208. bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`)
  209. openMessageTurn(session, 3)
  210. await fire(ctx, agent, 3, 1)
  211. const texts = contextTexts(session)
  212. expect(texts).toHaveLength(2)
  213. expect(texts[1]).toContain('tmux location (turn 3):')
  214. expect(texts[1]).toContain('window 1 "shell", pane 2 %12')
  215. })
  216. it('honors a positive refresh interval between injections', async () => {
  217. vi.useFakeTimers()
  218. vi.setSystemTime(1_000)
  219. const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true)
  220. const session = Session.create(SessionId('interval'))
  221. const agent = sessionAgent(session)
  222. openMessageTurn(session, 1)
  223. await fire(ctx, agent, 1, 1)
  224. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  225. // Changed state but inside the interval: suppressed, and never queried.
  226. bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`)
  227. vi.setSystemTime(5_000)
  228. openMessageTurn(session, 2)
  229. await fire(ctx, agent, 2, 1)
  230. expect(contextTexts(session)).toHaveLength(1)
  231. expect(bash.commands).toHaveLength(1)
  232. // Past the interval: queried and re-injected.
  233. vi.setSystemTime(12_000)
  234. openMessageTurn(session, 3)
  235. await fire(ctx, agent, 3, 1)
  236. expect(contextTexts(session)).toHaveLength(2)
  237. expect(bash.commands).toHaveLength(2)
  238. })
  239. })
  240. describe('tmux-context prior-reading resilience', () => {
  241. it('treats a prior non-text plugin reading as absent and injects afresh', async () => {
  242. const { ctx, bash } = await mount({}, true)
  243. const session = Session.create(SessionId('prior-non-text'))
  244. const agent = sessionAgent(session)
  245. openMessageTurn(session, 1)
  246. session.append('user/message', createUserMessage({
  247. content: [{ type: 'reasoning', text: 'not a location' }],
  248. source: { kind: 'plugin', plugin: 'tmux-context' },
  249. }), { surfaceOp: 'append' })
  250. await fire(ctx, agent, 1, 1)
  251. expect(bash.commands).toHaveLength(1)
  252. expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):')
  253. })
  254. it('treats a prior single-line plugin reading (no newline) as empty state', async () => {
  255. const { ctx, bash } = await mount({}, true)
  256. const session = Session.create(SessionId('prior-single-line'))
  257. const agent = sessionAgent(session)
  258. openMessageTurn(session, 1)
  259. session.append('user/message', createUserMessage({
  260. content: [{ type: 'text', text: 'single line, no newline' }],
  261. source: { kind: 'plugin', plugin: 'tmux-context' },
  262. }), { surfaceOp: 'append' })
  263. await fire(ctx, agent, 1, 1)
  264. // Empty prior state never equals the multi-line reading, so it re-injects.
  265. expect(bash.commands).toHaveLength(1)
  266. expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):')
  267. })
  268. })
  269. describe('tmux-context no-op paths', () => {
  270. it('is a no-op when no bash executor is mounted', async () => {
  271. const { ctx } = await mount()
  272. const session = Session.create(SessionId('no-bash'))
  273. openMessageTurn(session, 1)
  274. await fire(ctx, sessionAgent(session), 1, 1)
  275. expect(contextTexts(session)).toHaveLength(0)
  276. })
  277. it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => {
  278. const { ctx, bash } = await mount({}, true)
  279. bash.result = runResult('', { exitCode: 1 })
  280. const session = Session.create(SessionId('outside-tmux'))
  281. openMessageTurn(session, 1)
  282. await fire(ctx, sessionAgent(session), 1, 1)
  283. expect(contextTexts(session)).toHaveLength(0)
  284. })
  285. it('is a no-op when the reading has the wrong field count', async () => {
  286. const { ctx, bash } = await mount({}, true)
  287. bash.result = runResult('0\\t1\\tnode\n')
  288. const session = Session.create(SessionId('malformed'))
  289. openMessageTurn(session, 1)
  290. await fire(ctx, sessionAgent(session), 1, 1)
  291. expect(contextTexts(session)).toHaveLength(0)
  292. })
  293. it('is a no-op when the pane id is empty', async () => {
  294. const { ctx, bash } = await mount({}, true)
  295. bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`)
  296. const session = Session.create(SessionId('empty-pane'))
  297. openMessageTurn(session, 1)
  298. await fire(ctx, sessionAgent(session), 1, 1)
  299. expect(contextTexts(session)).toHaveLength(0)
  300. })
  301. it('warns and injects nothing when the executor rejects the run', async () => {
  302. const { ctx, bash } = await mount({}, true)
  303. bash.runError = new Error('bash executor unavailable')
  304. const warn = vi.spyOn(ctx.logger, 'warn')
  305. const session = Session.create(SessionId('run-rejected'))
  306. openMessageTurn(session, 1)
  307. await fire(ctx, sessionAgent(session), 1, 1)
  308. expect(contextTexts(session)).toHaveLength(0)
  309. expect(warn).toHaveBeenCalledWith(expect.stringContaining('bash executor unavailable'))
  310. })
  311. it('warns and injects nothing when the executor rejects the command at resolve', async () => {
  312. const { ctx, bash } = await mount({}, true)
  313. bash.resolveError = new Error('command denied by policy')
  314. const warn = vi.spyOn(ctx.logger, 'warn')
  315. const session = Session.create(SessionId('resolve-rejected'))
  316. openMessageTurn(session, 1)
  317. await fire(ctx, sessionAgent(session), 1, 1)
  318. expect(contextTexts(session)).toHaveLength(0)
  319. expect(warn).toHaveBeenCalledWith(expect.stringContaining('command denied by policy'))
  320. })
  321. it('reports a non-Error rejection in the warning', async () => {
  322. const { ctx, bash } = await mount({}, true)
  323. // Non-Error throw: the executor seam is typed, but a bad impl can reject with anything.
  324. bash.runError = 'spawn refused' as unknown as Error
  325. const warn = vi.spyOn(ctx.logger, 'warn')
  326. const session = Session.create(SessionId('non-error-rejection'))
  327. openMessageTurn(session, 1)
  328. await fire(ctx, sessionAgent(session), 1, 1)
  329. expect(contextTexts(session)).toHaveLength(0)
  330. expect(warn).toHaveBeenCalledWith(expect.stringContaining('spawn refused'))
  331. })
  332. it('skips an already-aborted prompt submission', async () => {
  333. const { ctx } = await mount({}, true)
  334. const session = Session.create(SessionId('ordering'))
  335. const agent = sessionAgent(session)
  336. openMessageTurn(session, 1)
  337. const abort = new AbortController()
  338. abort.abort()
  339. await fire(ctx, agent, 1, 1, abort.signal)
  340. expect(contextTexts(session)).toHaveLength(0)
  341. await fire(ctx, agent, 1, 1)
  342. expect(contextTexts(session)).toHaveLength(1)
  343. })
  344. })
  345. describe('tmux-context configuration', () => {
  346. it('rejects a negative refresh interval at plugin load', async () => {
  347. await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow(
  348. /refreshIntervalMs must be a non-negative safe integer/,
  349. )
  350. })
  351. it('rejects a non-integer refresh interval at plugin load', async () => {
  352. await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow(
  353. /refreshIntervalMs must be a non-negative safe integer/,
  354. )
  355. })
  356. })